Skip to main content

fakecloud_cloudfront/
service.rs

1//! CloudFront REST-XML service implementation.
2
3use std::sync::Arc;
4
5use async_trait::async_trait;
6use bytes::Bytes;
7use chrono::Utc;
8use http::header::{HeaderName, HeaderValue, ETAG, IF_MATCH, LOCATION};
9use http::{HeaderMap, StatusCode};
10use parking_lot::RwLock;
11use uuid::Uuid;
12
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError, ResponseBody};
14
15use crate::model::{
16    DistributionConfig, DistributionConfigWithTags, InvalidationBatch, TagKeys, Tags as ModelTags,
17};
18use crate::router::{route, Route};
19use crate::state::{
20    CloudFrontAccounts, SharedCloudFrontState, StoredDistribution, StoredInvalidation, Tag,
21};
22use crate::xml_io;
23
24pub(crate) const DEFAULT_ACCOUNT: &str = "000000000000";
25
26const SUPPORTED_ACTIONS: &[&str] = &[
27    "CreateDistribution",
28    "CreateDistributionWithTags",
29    "GetDistribution",
30    "GetDistributionConfig",
31    "UpdateDistribution",
32    "DeleteDistribution",
33    "ListDistributions",
34    "CopyDistribution",
35    "CreateInvalidation",
36    "GetInvalidation",
37    "ListInvalidations",
38    "TagResource",
39    "UntagResource",
40    "ListTagsForResource",
41    "AssociateAlias",
42    "ListConflictingAliases",
43    "ListDistributionsByCachePolicyId",
44    "ListDistributionsByOriginRequestPolicyId",
45    "ListDistributionsByResponseHeadersPolicyId",
46    "ListDistributionsByKeyGroup",
47    "ListDistributionsByWebACLId",
48    "ListDistributionsByVpcOriginId",
49    "ListDistributionsByAnycastIpListId",
50    "ListDistributionsByConnectionMode",
51    "ListDistributionsByConnectionFunction",
52    "ListDistributionsByOwnedResource",
53    "ListDistributionsByTrustStore",
54    "ListDistributionsByRealtimeLogConfig",
55    "AssociateDistributionWebACL",
56    "DisassociateDistributionWebACL",
57    "CreateOriginAccessControl",
58    "GetOriginAccessControl",
59    "GetOriginAccessControlConfig",
60    "UpdateOriginAccessControl",
61    "DeleteOriginAccessControl",
62    "ListOriginAccessControls",
63    "CreateCachePolicy",
64    "GetCachePolicy",
65    "GetCachePolicyConfig",
66    "UpdateCachePolicy",
67    "DeleteCachePolicy",
68    "ListCachePolicies",
69    "CreateOriginRequestPolicy",
70    "GetOriginRequestPolicy",
71    "GetOriginRequestPolicyConfig",
72    "UpdateOriginRequestPolicy",
73    "DeleteOriginRequestPolicy",
74    "ListOriginRequestPolicies",
75    "CreateResponseHeadersPolicy",
76    "GetResponseHeadersPolicy",
77    "GetResponseHeadersPolicyConfig",
78    "UpdateResponseHeadersPolicy",
79    "DeleteResponseHeadersPolicy",
80    "ListResponseHeadersPolicies",
81    "CreateContinuousDeploymentPolicy",
82    "GetContinuousDeploymentPolicy",
83    "GetContinuousDeploymentPolicyConfig",
84    "UpdateContinuousDeploymentPolicy",
85    "DeleteContinuousDeploymentPolicy",
86    "ListContinuousDeploymentPolicies",
87    "CreateFunction",
88    "DescribeFunction",
89    "GetFunction",
90    "UpdateFunction",
91    "DeleteFunction",
92    "ListFunctions",
93    "PublishFunction",
94    "TestFunction",
95    "CreatePublicKey",
96    "GetPublicKey",
97    "GetPublicKeyConfig",
98    "UpdatePublicKey",
99    "DeletePublicKey",
100    "ListPublicKeys",
101    "CreateKeyGroup",
102    "GetKeyGroup",
103    "GetKeyGroupConfig",
104    "UpdateKeyGroup",
105    "DeleteKeyGroup",
106    "ListKeyGroups",
107    "CreateKeyValueStore",
108    "DescribeKeyValueStore",
109    "UpdateKeyValueStore",
110    "DeleteKeyValueStore",
111    "ListKeyValueStores",
112    "CreateCloudFrontOriginAccessIdentity",
113    "GetCloudFrontOriginAccessIdentity",
114    "GetCloudFrontOriginAccessIdentityConfig",
115    "UpdateCloudFrontOriginAccessIdentity",
116    "DeleteCloudFrontOriginAccessIdentity",
117    "ListCloudFrontOriginAccessIdentities",
118    "CreateMonitoringSubscription",
119    "GetMonitoringSubscription",
120    "DeleteMonitoringSubscription",
121    "CreateStreamingDistribution",
122    "CreateStreamingDistributionWithTags",
123    "GetStreamingDistribution",
124    "GetStreamingDistributionConfig",
125    "UpdateStreamingDistribution",
126    "DeleteStreamingDistribution",
127    "ListStreamingDistributions",
128    "CreateFieldLevelEncryptionConfig",
129    "GetFieldLevelEncryption",
130    "GetFieldLevelEncryptionConfig",
131    "UpdateFieldLevelEncryptionConfig",
132    "DeleteFieldLevelEncryptionConfig",
133    "ListFieldLevelEncryptionConfigs",
134    "CreateFieldLevelEncryptionProfile",
135    "GetFieldLevelEncryptionProfile",
136    "GetFieldLevelEncryptionProfileConfig",
137    "UpdateFieldLevelEncryptionProfile",
138    "DeleteFieldLevelEncryptionProfile",
139    "ListFieldLevelEncryptionProfiles",
140    "CreateRealtimeLogConfig",
141    "GetRealtimeLogConfig",
142    "UpdateRealtimeLogConfig",
143    "DeleteRealtimeLogConfig",
144    "ListRealtimeLogConfigs",
145    "CreateVpcOrigin",
146    "GetVpcOrigin",
147    "UpdateVpcOrigin",
148    "DeleteVpcOrigin",
149    "ListVpcOrigins",
150    "CreateAnycastIpList",
151    "GetAnycastIpList",
152    "UpdateAnycastIpList",
153    "DeleteAnycastIpList",
154    "ListAnycastIpLists",
155    "CreateTrustStore",
156    "GetTrustStore",
157    "UpdateTrustStore",
158    "DeleteTrustStore",
159    "ListTrustStores",
160    "GetResourcePolicy",
161    "PutResourcePolicy",
162    "DeleteResourcePolicy",
163    "CreateConnectionGroup",
164    "GetConnectionGroup",
165    "GetConnectionGroupByRoutingEndpoint",
166    "UpdateConnectionGroup",
167    "DeleteConnectionGroup",
168    "ListConnectionGroups",
169    "ListDomainConflicts",
170    "UpdateDomainAssociation",
171    "VerifyDnsConfiguration",
172    "GetManagedCertificateDetails",
173    "UpdateDistributionWithStagingConfig",
174];
175
176pub struct CloudFrontService {
177    pub(crate) state: SharedCloudFrontState,
178}
179
180impl CloudFrontService {
181    pub fn new(state: SharedCloudFrontState) -> Self {
182        Self { state }
183    }
184
185    pub fn shared_state(&self) -> SharedCloudFrontState {
186        Arc::clone(&self.state)
187    }
188}
189
190impl Default for CloudFrontService {
191    fn default() -> Self {
192        Self::new(Arc::new(RwLock::new(CloudFrontAccounts::new())))
193    }
194}
195
196#[async_trait]
197impl AwsService for CloudFrontService {
198    fn service_name(&self) -> &str {
199        "cloudfront"
200    }
201
202    fn supported_actions(&self) -> &[&str] {
203        SUPPORTED_ACTIONS
204    }
205
206    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
207        let resolved = match route(&req.method, &req.raw_path, &req.raw_query) {
208            Some(r) => r,
209            None => {
210                return Err(aws_error(
211                    StatusCode::NOT_FOUND,
212                    "InvalidArgument",
213                    format!("Unknown CloudFront route: {} {}", req.method, req.raw_path),
214                ));
215            }
216        };
217
218        match resolved.action {
219            "CreateDistribution" => self.create_distribution(&req, false),
220            "CreateDistributionWithTags" => self.create_distribution(&req, true),
221            "GetDistribution" => self.get_distribution(&resolved),
222            "GetDistributionConfig" => self.get_distribution_config(&resolved),
223            "UpdateDistribution" => self.update_distribution(&req, &resolved),
224            "DeleteDistribution" => self.delete_distribution(&req, &resolved),
225            "ListDistributions" => self.list_distributions(&req),
226            "CopyDistribution" => self.copy_distribution(&req, &resolved),
227            "CreateInvalidation" => self.create_invalidation(&req, &resolved),
228            "GetInvalidation" => self.get_invalidation(&resolved),
229            "ListInvalidations" => self.list_invalidations(&resolved),
230            "TagResource" => self.tag_resource(&req),
231            "UntagResource" => self.untag_resource(&req),
232            "ListTagsForResource" => self.list_tags_for_resource(&req),
233            "AssociateAlias" => self.associate_alias(&req, &resolved),
234            "ListConflictingAliases" => self.list_conflicting_aliases(&req),
235            "AssociateDistributionWebACL" => self.associate_web_acl(&req, &resolved),
236            "DisassociateDistributionWebACL" => self.disassociate_web_acl(&req, &resolved),
237            "ListDistributionsByCachePolicyId"
238            | "ListDistributionsByOriginRequestPolicyId"
239            | "ListDistributionsByResponseHeadersPolicyId"
240            | "ListDistributionsByKeyGroup"
241            | "ListDistributionsByWebACLId"
242            | "ListDistributionsByVpcOriginId"
243            | "ListDistributionsByAnycastIpListId"
244            | "ListDistributionsByConnectionMode"
245            | "ListDistributionsByConnectionFunction"
246            | "ListDistributionsByOwnedResource"
247            | "ListDistributionsByTrustStore"
248            | "ListDistributionsByRealtimeLogConfig" => self.list_distributions_by(resolved.action),
249            "CreateOriginAccessControl" => self.create_origin_access_control(&req),
250            "GetOriginAccessControl" => self.get_origin_access_control(&resolved),
251            "GetOriginAccessControlConfig" => self.get_origin_access_control_config(&resolved),
252            "UpdateOriginAccessControl" => self.update_origin_access_control(&req, &resolved),
253            "DeleteOriginAccessControl" => self.delete_origin_access_control(&req, &resolved),
254            "ListOriginAccessControls" => self.list_origin_access_controls(&req),
255            "CreateCachePolicy" => self.create_cache_policy(&req),
256            "GetCachePolicy" => self.get_cache_policy(&resolved),
257            "GetCachePolicyConfig" => self.get_cache_policy_config(&resolved),
258            "UpdateCachePolicy" => self.update_cache_policy(&req, &resolved),
259            "DeleteCachePolicy" => self.delete_cache_policy(&req, &resolved),
260            "ListCachePolicies" => self.list_cache_policies(&req),
261            "CreateOriginRequestPolicy" => self.create_origin_request_policy(&req),
262            "GetOriginRequestPolicy" => self.get_origin_request_policy(&resolved),
263            "GetOriginRequestPolicyConfig" => self.get_origin_request_policy_config(&resolved),
264            "UpdateOriginRequestPolicy" => self.update_origin_request_policy(&req, &resolved),
265            "DeleteOriginRequestPolicy" => self.delete_origin_request_policy(&req, &resolved),
266            "ListOriginRequestPolicies" => self.list_origin_request_policies(&req),
267            "CreateResponseHeadersPolicy" => self.create_response_headers_policy(&req),
268            "GetResponseHeadersPolicy" => self.get_response_headers_policy(&resolved),
269            "GetResponseHeadersPolicyConfig" => self.get_response_headers_policy_config(&resolved),
270            "UpdateResponseHeadersPolicy" => self.update_response_headers_policy(&req, &resolved),
271            "DeleteResponseHeadersPolicy" => self.delete_response_headers_policy(&req, &resolved),
272            "ListResponseHeadersPolicies" => self.list_response_headers_policies(&req),
273            "CreateContinuousDeploymentPolicy" => self.create_continuous_deployment_policy(&req),
274            "GetContinuousDeploymentPolicy" => self.get_continuous_deployment_policy(&resolved),
275            "GetContinuousDeploymentPolicyConfig" => {
276                self.get_continuous_deployment_policy_config(&resolved)
277            }
278            "UpdateContinuousDeploymentPolicy" => {
279                self.update_continuous_deployment_policy(&req, &resolved)
280            }
281            "DeleteContinuousDeploymentPolicy" => {
282                self.delete_continuous_deployment_policy(&req, &resolved)
283            }
284            "ListContinuousDeploymentPolicies" => self.list_continuous_deployment_policies(&req),
285            "CreateFunction" => self.create_function(&req),
286            "DescribeFunction" => self.describe_function(&req, &resolved),
287            "GetFunction" => self.get_function(&req, &resolved),
288            "UpdateFunction" => self.update_function(&req, &resolved),
289            "DeleteFunction" => self.delete_function(&req, &resolved),
290            "ListFunctions" => self.list_functions(&req),
291            "PublishFunction" => self.publish_function(&req, &resolved),
292            "TestFunction" => self.test_function(&req, &resolved),
293            "CreatePublicKey" => self.create_public_key(&req),
294            "GetPublicKey" => self.get_public_key(&resolved),
295            "GetPublicKeyConfig" => self.get_public_key_config(&resolved),
296            "UpdatePublicKey" => self.update_public_key(&req, &resolved),
297            "DeletePublicKey" => self.delete_public_key(&req, &resolved),
298            "ListPublicKeys" => self.list_public_keys(&req),
299            "CreateKeyGroup" => self.create_key_group(&req),
300            "GetKeyGroup" => self.get_key_group(&resolved),
301            "GetKeyGroupConfig" => self.get_key_group_config(&resolved),
302            "UpdateKeyGroup" => self.update_key_group(&req, &resolved),
303            "DeleteKeyGroup" => self.delete_key_group(&req, &resolved),
304            "ListKeyGroups" => self.list_key_groups(&req),
305            "CreateKeyValueStore" => self.create_key_value_store(&req),
306            "DescribeKeyValueStore" => self.describe_key_value_store(&resolved),
307            "UpdateKeyValueStore" => self.update_key_value_store(&req, &resolved),
308            "DeleteKeyValueStore" => self.delete_key_value_store(&req, &resolved),
309            "ListKeyValueStores" => self.list_key_value_stores(&req),
310            "CreateCloudFrontOriginAccessIdentity" => self.create_oai(&req),
311            "GetCloudFrontOriginAccessIdentity" => self.get_oai(&resolved),
312            "GetCloudFrontOriginAccessIdentityConfig" => self.get_oai_config(&resolved),
313            "UpdateCloudFrontOriginAccessIdentity" => self.update_oai(&req, &resolved),
314            "DeleteCloudFrontOriginAccessIdentity" => self.delete_oai(&req, &resolved),
315            "ListCloudFrontOriginAccessIdentities" => self.list_oai(&req),
316            "CreateMonitoringSubscription" => self.create_monitoring_subscription(&req, &resolved),
317            "GetMonitoringSubscription" => self.get_monitoring_subscription(&resolved),
318            "DeleteMonitoringSubscription" => self.delete_monitoring_subscription(&resolved),
319            "CreateStreamingDistribution" => self.create_streaming_distribution(&req, false),
320            "CreateStreamingDistributionWithTags" => self.create_streaming_distribution(&req, true),
321            "GetStreamingDistribution" => self.get_streaming_distribution(&resolved),
322            "GetStreamingDistributionConfig" => self.get_streaming_distribution_config(&resolved),
323            "UpdateStreamingDistribution" => self.update_streaming_distribution(&req, &resolved),
324            "DeleteStreamingDistribution" => self.delete_streaming_distribution(&req, &resolved),
325            "ListStreamingDistributions" => self.list_streaming_distributions(&req),
326            "CreateFieldLevelEncryptionConfig" => self.create_field_level_encryption_config(&req),
327            "GetFieldLevelEncryption" => self.get_field_level_encryption(&resolved),
328            "GetFieldLevelEncryptionConfig" => self.get_field_level_encryption_config(&resolved),
329            "UpdateFieldLevelEncryptionConfig" => {
330                self.update_field_level_encryption_config(&req, &resolved)
331            }
332            "DeleteFieldLevelEncryptionConfig" => {
333                self.delete_field_level_encryption_config(&req, &resolved)
334            }
335            "ListFieldLevelEncryptionConfigs" => self.list_field_level_encryption_configs(&req),
336            "CreateFieldLevelEncryptionProfile" => self.create_field_level_encryption_profile(&req),
337            "GetFieldLevelEncryptionProfile" => self.get_field_level_encryption_profile(&resolved),
338            "GetFieldLevelEncryptionProfileConfig" => {
339                self.get_field_level_encryption_profile_config(&resolved)
340            }
341            "UpdateFieldLevelEncryptionProfile" => {
342                self.update_field_level_encryption_profile(&req, &resolved)
343            }
344            "DeleteFieldLevelEncryptionProfile" => {
345                self.delete_field_level_encryption_profile(&req, &resolved)
346            }
347            "ListFieldLevelEncryptionProfiles" => self.list_field_level_encryption_profiles(&req),
348            "CreateRealtimeLogConfig" => self.create_realtime_log_config(&req),
349            "GetRealtimeLogConfig" => self.get_realtime_log_config(&req),
350            "UpdateRealtimeLogConfig" => self.update_realtime_log_config(&req),
351            "DeleteRealtimeLogConfig" => self.delete_realtime_log_config(&req),
352            "ListRealtimeLogConfigs" => self.list_realtime_log_configs(&req),
353            "CreateVpcOrigin" => self.create_vpc_origin(&req),
354            "GetVpcOrigin" => self.get_vpc_origin(&resolved),
355            "UpdateVpcOrigin" => self.update_vpc_origin(&req, &resolved),
356            "DeleteVpcOrigin" => self.delete_vpc_origin(&req, &resolved),
357            "ListVpcOrigins" => self.list_vpc_origins(&req),
358            "CreateAnycastIpList" => self.create_anycast_ip_list(&req),
359            "GetAnycastIpList" => self.get_anycast_ip_list(&resolved),
360            "UpdateAnycastIpList" => self.update_anycast_ip_list(&req, &resolved),
361            "DeleteAnycastIpList" => self.delete_anycast_ip_list(&req, &resolved),
362            "ListAnycastIpLists" => self.list_anycast_ip_lists(&req),
363            "CreateTrustStore" => self.create_trust_store(&req),
364            "GetTrustStore" => self.get_trust_store(&resolved),
365            "UpdateTrustStore" => self.update_trust_store(&req, &resolved),
366            "DeleteTrustStore" => self.delete_trust_store(&req, &resolved),
367            "ListTrustStores" => self.list_trust_stores(&req),
368            "GetResourcePolicy" => self.get_resource_policy(&req),
369            "PutResourcePolicy" => self.put_resource_policy(&req),
370            "DeleteResourcePolicy" => self.delete_resource_policy(&req),
371            "CreateConnectionGroup" => self.create_connection_group(&req),
372            "GetConnectionGroup" => self.get_connection_group(&resolved),
373            "GetConnectionGroupByRoutingEndpoint" => {
374                self.get_connection_group_by_routing_endpoint(&req)
375            }
376            "UpdateConnectionGroup" => self.update_connection_group(&req, &resolved),
377            "DeleteConnectionGroup" => self.delete_connection_group(&req, &resolved),
378            "ListConnectionGroups" => self.list_connection_groups(&req),
379            "ListDomainConflicts" => self.list_domain_conflicts(&req),
380            "UpdateDomainAssociation" => self.update_domain_association(&req),
381            "VerifyDnsConfiguration" => self.verify_dns_configuration(&req),
382            "GetManagedCertificateDetails" => self.get_managed_certificate_details(&resolved),
383            "UpdateDistributionWithStagingConfig" => {
384                self.update_distribution_with_staging_config(&req, &resolved)
385            }
386            other => Err(aws_error(
387                StatusCode::NOT_IMPLEMENTED,
388                "InvalidAction",
389                format!("CloudFront action {other} is not implemented yet"),
390            )),
391        }
392    }
393}
394
395// ─── Distribution handlers ────────────────────────────────────────────
396
397impl CloudFrontService {
398    fn create_distribution(
399        &self,
400        req: &AwsRequest,
401        with_tags: bool,
402    ) -> Result<AwsResponse, AwsServiceError> {
403        let (config, tags) = if with_tags {
404            let parsed: DistributionConfigWithTags = xml_io::from_xml_root(&req.body)
405                .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
406            let tags = parsed
407                .tags
408                .items
409                .map(|i| {
410                    i.tag
411                        .into_iter()
412                        .map(|t| Tag {
413                            key: t.key,
414                            value: t.value,
415                        })
416                        .collect()
417                })
418                .unwrap_or_default();
419            (parsed.distribution_config, tags)
420        } else {
421            let parsed: DistributionConfig = xml_io::from_xml_root(&req.body)
422                .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
423            (parsed, Vec::new())
424        };
425
426        validate_caller_reference(&config.caller_reference)?;
427        validate_origins(&config)?;
428
429        let mut state = self.state.write();
430        let account = state.entry(account_id(req));
431
432        if let Some(existing) = account
433            .distributions
434            .values()
435            .find(|d| d.config.caller_reference == config.caller_reference)
436        {
437            return Err(aws_error(
438                StatusCode::CONFLICT,
439                "DistributionAlreadyExists",
440                format!(
441                    "Distribution with the same CallerReference exists: {}",
442                    existing.id
443                ),
444            ));
445        }
446
447        let id = generate_distribution_id();
448        let now = Utc::now();
449        let etag = generate_etag();
450        let domain = format!("{}.cloudfront.net", id.to_lowercase());
451        let arn = format!(
452            "arn:aws:cloudfront::{}:distribution/{}",
453            account_id(req),
454            id
455        );
456
457        let stored = StoredDistribution {
458            id: id.clone(),
459            arn: arn.clone(),
460            status: "Deployed".to_string(),
461            last_modified_time: now,
462            domain_name: domain,
463            in_progress_invalidation_batches: 0,
464            etag: etag.clone(),
465            config,
466        };
467        account.distributions.insert(id.clone(), stored.clone());
468        if !tags.is_empty() {
469            account.tags.insert(arn.clone(), tags);
470        }
471        drop(state);
472
473        let body = build_distribution_xml(&stored);
474        let mut headers = HeaderMap::new();
475        set_header(&mut headers, ETAG, &etag);
476        set_header(&mut headers, LOCATION, &stored.arn);
477        Ok(xml_response(StatusCode::CREATED, body, headers))
478    }
479
480    fn get_distribution(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
481        let id = route
482            .id
483            .as_deref()
484            .ok_or_else(|| invalid_argument("missing distribution id"))?;
485        let state = self.state.read();
486        let account = state
487            .accounts
488            .get(DEFAULT_ACCOUNT)
489            .ok_or_else(|| no_such_distribution(id))?;
490        let dist = account
491            .distributions
492            .get(id)
493            .ok_or_else(|| no_such_distribution(id))?
494            .clone();
495        drop(state);
496        let body = build_distribution_xml(&dist);
497        let mut headers = HeaderMap::new();
498        set_header(&mut headers, ETAG, &dist.etag);
499        Ok(xml_response(StatusCode::OK, body, headers))
500    }
501
502    fn get_distribution_config(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
503        let id = route
504            .id
505            .as_deref()
506            .ok_or_else(|| invalid_argument("missing distribution id"))?;
507        let state = self.state.read();
508        let account = state
509            .accounts
510            .get(DEFAULT_ACCOUNT)
511            .ok_or_else(|| no_such_distribution(id))?;
512        let dist = account
513            .distributions
514            .get(id)
515            .ok_or_else(|| no_such_distribution(id))?
516            .clone();
517        drop(state);
518        let body = xml_io::to_xml_root("DistributionConfig", &dist.config)
519            .map_err(|e| internal_error(format!("xml encode failed: {e}")))?;
520        let mut headers = HeaderMap::new();
521        set_header(&mut headers, ETAG, &dist.etag);
522        Ok(xml_response(StatusCode::OK, body, headers))
523    }
524
525    fn update_distribution(
526        &self,
527        req: &AwsRequest,
528        route: &Route,
529    ) -> Result<AwsResponse, AwsServiceError> {
530        let id = route
531            .id
532            .as_deref()
533            .ok_or_else(|| invalid_argument("missing distribution id"))?;
534        let if_match = req
535            .headers
536            .get(IF_MATCH)
537            .and_then(|v| v.to_str().ok())
538            .ok_or_else(|| {
539                aws_error(
540                    StatusCode::BAD_REQUEST,
541                    "InvalidIfMatchVersion",
542                    "Missing If-Match header for UpdateDistribution",
543                )
544            })?
545            .to_string();
546        let new_config: DistributionConfig = xml_io::from_xml_root(&req.body)
547            .map_err(|e| invalid_argument(format!("invalid DistributionConfig XML: {e}")))?;
548        validate_caller_reference(&new_config.caller_reference)?;
549        validate_origins(&new_config)?;
550
551        let mut state = self.state.write();
552        let account = state
553            .accounts
554            .get_mut(DEFAULT_ACCOUNT)
555            .ok_or_else(|| no_such_distribution(id))?;
556        let dist = account
557            .distributions
558            .get_mut(id)
559            .ok_or_else(|| no_such_distribution(id))?;
560        if dist.etag != if_match {
561            return Err(aws_error(
562                StatusCode::PRECONDITION_FAILED,
563                "PreconditionFailed",
564                "If-Match header does not match the current ETag",
565            ));
566        }
567        dist.config = new_config;
568        dist.etag = generate_etag();
569        dist.last_modified_time = Utc::now();
570        let snapshot = dist.clone();
571        drop(state);
572
573        let body = build_distribution_xml(&snapshot);
574        let mut headers = HeaderMap::new();
575        set_header(&mut headers, ETAG, &snapshot.etag);
576        Ok(xml_response(StatusCode::OK, body, headers))
577    }
578
579    fn delete_distribution(
580        &self,
581        req: &AwsRequest,
582        route: &Route,
583    ) -> Result<AwsResponse, AwsServiceError> {
584        let id = route
585            .id
586            .as_deref()
587            .ok_or_else(|| invalid_argument("missing distribution id"))?;
588        let if_match = req
589            .headers
590            .get(IF_MATCH)
591            .and_then(|v| v.to_str().ok())
592            .ok_or_else(|| {
593                aws_error(
594                    StatusCode::BAD_REQUEST,
595                    "InvalidIfMatchVersion",
596                    "Missing If-Match header for DeleteDistribution",
597                )
598            })?
599            .to_string();
600        let mut state = self.state.write();
601        let account = state
602            .accounts
603            .get_mut(DEFAULT_ACCOUNT)
604            .ok_or_else(|| no_such_distribution(id))?;
605        {
606            let dist = account
607                .distributions
608                .get(id)
609                .ok_or_else(|| no_such_distribution(id))?;
610            if dist.etag != if_match {
611                return Err(aws_error(
612                    StatusCode::PRECONDITION_FAILED,
613                    "PreconditionFailed",
614                    "If-Match header does not match the current ETag",
615                ));
616            }
617            if dist.config.enabled {
618                return Err(aws_error(
619                    StatusCode::PRECONDITION_FAILED,
620                    "DistributionNotDisabled",
621                    "Distribution must be disabled before delete",
622                ));
623            }
624        }
625        let removed = account.distributions.remove(id).unwrap();
626        account.tags.remove(&removed.arn);
627        Ok(empty_response(StatusCode::NO_CONTENT))
628    }
629
630    fn list_distributions(&self, _req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
631        let state = self.state.read();
632        let mut dists: Vec<StoredDistribution> = state
633            .accounts
634            .values()
635            .flat_map(|a| a.distributions.values().cloned())
636            .collect();
637        dists.sort_by_key(|a| a.last_modified_time);
638        drop(state);
639        let body = build_distribution_list_xml(&dists, "DistributionList");
640        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
641    }
642
643    fn list_distributions_by(&self, action: &str) -> Result<AwsResponse, AwsServiceError> {
644        // The "by-X" listings each have a distinct response root element.
645        // We never index distributions by the predicate (that would require
646        // shipping each policy/key-group/etc service first), so each
647        // response is empty until those services land.
648        let root = match action {
649            "ListDistributionsByCachePolicyId"
650            | "ListDistributionsByOriginRequestPolicyId"
651            | "ListDistributionsByResponseHeadersPolicyId"
652            | "ListDistributionsByKeyGroup"
653            | "ListDistributionsByVpcOriginId" => "DistributionIdList",
654            "ListDistributionsByOwnedResource" => "DistributionIdOwnerList",
655            _ => "DistributionList",
656        };
657        let body = build_empty_distribution_id_list(root);
658        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
659    }
660
661    fn copy_distribution(
662        &self,
663        req: &AwsRequest,
664        route: &Route,
665    ) -> Result<AwsResponse, AwsServiceError> {
666        let primary_id = route
667            .id
668            .as_deref()
669            .ok_or_else(|| invalid_argument("missing primary distribution id"))?;
670        let if_match = req
671            .headers
672            .get(IF_MATCH)
673            .and_then(|v| v.to_str().ok())
674            .ok_or_else(|| {
675                aws_error(
676                    StatusCode::BAD_REQUEST,
677                    "InvalidIfMatchVersion",
678                    "Missing If-Match header for CopyDistribution",
679                )
680            })?
681            .to_string();
682        let parsed: CopyDistributionRequest = xml_io::from_xml_root(&req.body)
683            .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
684        validate_caller_reference(&parsed.caller_reference)?;
685        let mut state = self.state.write();
686        let account = state
687            .accounts
688            .get_mut(DEFAULT_ACCOUNT)
689            .ok_or_else(|| no_such_distribution(primary_id))?;
690        let primary = account
691            .distributions
692            .get(primary_id)
693            .ok_or_else(|| no_such_distribution(primary_id))?
694            .clone();
695        if primary.etag != if_match {
696            return Err(aws_error(
697                StatusCode::PRECONDITION_FAILED,
698                "PreconditionFailed",
699                "If-Match header does not match the current ETag",
700            ));
701        }
702        if account
703            .distributions
704            .values()
705            .any(|d| d.config.caller_reference == parsed.caller_reference)
706        {
707            return Err(aws_error(
708                StatusCode::CONFLICT,
709                "DistributionAlreadyExists",
710                "Distribution with the same CallerReference exists",
711            ));
712        }
713        let new_id = generate_distribution_id();
714        let mut config = primary.config.clone();
715        config.caller_reference = parsed.caller_reference;
716        config.enabled = parsed.enabled.unwrap_or(false);
717        config.staging = parsed.staging;
718        let now = Utc::now();
719        let etag = generate_etag();
720        let arn = format!(
721            "arn:aws:cloudfront::{}:distribution/{}",
722            account_id(req),
723            new_id
724        );
725        let stored = StoredDistribution {
726            id: new_id.clone(),
727            arn: arn.clone(),
728            status: "Deployed".into(),
729            last_modified_time: now,
730            domain_name: format!("{}.cloudfront.net", new_id.to_lowercase()),
731            in_progress_invalidation_batches: 0,
732            etag: etag.clone(),
733            config,
734        };
735        account.distributions.insert(new_id.clone(), stored.clone());
736        drop(state);
737        let body = build_distribution_xml(&stored);
738        let mut headers = HeaderMap::new();
739        set_header(&mut headers, ETAG, &etag);
740        set_header(&mut headers, LOCATION, &stored.arn);
741        Ok(xml_response(StatusCode::CREATED, body, headers))
742    }
743}
744
745#[derive(Debug, serde::Deserialize, Default)]
746#[serde(rename_all = "PascalCase")]
747struct CopyDistributionRequest {
748    caller_reference: String,
749    #[serde(default)]
750    enabled: Option<bool>,
751    #[serde(default)]
752    staging: Option<bool>,
753}
754
755// ─── Invalidations ────────────────────────────────────────────────────
756
757impl CloudFrontService {
758    fn create_invalidation(
759        &self,
760        req: &AwsRequest,
761        route: &Route,
762    ) -> Result<AwsResponse, AwsServiceError> {
763        let dist_id = route
764            .id
765            .as_deref()
766            .ok_or_else(|| invalid_argument("missing distribution id"))?;
767        let batch: InvalidationBatch = xml_io::from_xml_root(&req.body)
768            .map_err(|e| invalid_argument(format!("invalid InvalidationBatch XML: {e}")))?;
769        if batch.caller_reference.is_empty() {
770            return Err(invalid_argument("CallerReference is required"));
771        }
772        if batch.paths.quantity < 1 {
773            return Err(invalid_argument(
774                "InvalidationBatch.Paths must be non-empty",
775            ));
776        }
777        let mut state = self.state.write();
778        let account = state.entry(DEFAULT_ACCOUNT);
779        if !account.distributions.contains_key(dist_id) {
780            return Err(no_such_distribution(dist_id));
781        }
782        let id = generate_invalidation_id();
783        let stored = StoredInvalidation {
784            id: id.clone(),
785            distribution_id: dist_id.to_string(),
786            status: "Completed".to_string(),
787            create_time: Utc::now(),
788            batch: batch.clone(),
789        };
790        account.invalidations.insert(id.clone(), stored.clone());
791        drop(state);
792        let body = build_invalidation_xml(&stored);
793        let mut headers = HeaderMap::new();
794        set_header(
795            &mut headers,
796            LOCATION,
797            &format!(
798                "/2020-05-31/distribution/{dist_id}/invalidation/{}",
799                stored.id
800            ),
801        );
802        Ok(xml_response(StatusCode::CREATED, body, headers))
803    }
804
805    fn get_invalidation(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
806        let dist_id = route
807            .id
808            .as_deref()
809            .ok_or_else(|| invalid_argument("missing distribution id"))?;
810        let inv_id = route
811            .second_id
812            .as_deref()
813            .ok_or_else(|| invalid_argument("missing invalidation id"))?;
814        let state = self.state.read();
815        let account = state
816            .accounts
817            .get(DEFAULT_ACCOUNT)
818            .ok_or_else(|| no_such_invalidation(inv_id))?;
819        if !account.distributions.contains_key(dist_id) {
820            return Err(no_such_distribution(dist_id));
821        }
822        let inv = account
823            .invalidations
824            .get(inv_id)
825            .filter(|i| i.distribution_id == dist_id)
826            .ok_or_else(|| no_such_invalidation(inv_id))?
827            .clone();
828        drop(state);
829        let body = build_invalidation_xml(&inv);
830        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
831    }
832
833    fn list_invalidations(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
834        let dist_id = route
835            .id
836            .as_deref()
837            .ok_or_else(|| invalid_argument("missing distribution id"))?;
838        let state = self.state.read();
839        let account = state
840            .accounts
841            .get(DEFAULT_ACCOUNT)
842            .ok_or_else(|| no_such_distribution(dist_id))?;
843        if !account.distributions.contains_key(dist_id) {
844            return Err(no_such_distribution(dist_id));
845        }
846        let mut items: Vec<&StoredInvalidation> = account
847            .invalidations
848            .values()
849            .filter(|i| i.distribution_id == dist_id)
850            .collect();
851        items.sort_by_key(|a| a.create_time);
852        let body = build_invalidation_list_xml(&items);
853        drop(state);
854        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
855    }
856}
857
858// ─── Tags ─────────────────────────────────────────────────────────────
859
860impl CloudFrontService {
861    fn parse_arn_query(query: &str) -> Option<String> {
862        for pair in query.split('&').filter(|p| !p.is_empty()) {
863            if let Some(rest) = pair.strip_prefix("Resource=") {
864                return Some(percent_decode(rest));
865            }
866        }
867        None
868    }
869
870    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
871        let arn = Self::parse_arn_query(&req.raw_query)
872            .ok_or_else(|| invalid_argument("Resource query parameter is required"))?;
873        let parsed: ModelTags = xml_io::from_xml_root(&req.body)
874            .map_err(|e| invalid_argument(format!("invalid Tags XML: {e}")))?;
875        let new_tags: Vec<Tag> = parsed
876            .items
877            .map(|i| {
878                i.tag
879                    .into_iter()
880                    .map(|t| Tag {
881                        key: t.key,
882                        value: t.value,
883                    })
884                    .collect()
885            })
886            .unwrap_or_default();
887        let mut state = self.state.write();
888        let account = state.entry(DEFAULT_ACCOUNT);
889        let entry = account.tags.entry(arn).or_default();
890        for tag in new_tags {
891            if let Some(existing) = entry.iter_mut().find(|t| t.key == tag.key) {
892                existing.value = tag.value;
893            } else {
894                entry.push(tag);
895            }
896        }
897        Ok(empty_response(StatusCode::NO_CONTENT))
898    }
899
900    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
901        let arn = Self::parse_arn_query(&req.raw_query)
902            .ok_or_else(|| invalid_argument("Resource query parameter is required"))?;
903        let parsed: TagKeys = xml_io::from_xml_root(&req.body)
904            .map_err(|e| invalid_argument(format!("invalid TagKeys XML: {e}")))?;
905        let keys: Vec<String> = parsed.items.map(|k| k.key).unwrap_or_default();
906        let mut state = self.state.write();
907        let account = state.entry(DEFAULT_ACCOUNT);
908        if let Some(existing) = account.tags.get_mut(&arn) {
909            existing.retain(|t| !keys.contains(&t.key));
910        }
911        Ok(empty_response(StatusCode::NO_CONTENT))
912    }
913
914    fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
915        let arn = Self::parse_arn_query(&req.raw_query)
916            .ok_or_else(|| invalid_argument("Resource query parameter is required"))?;
917        let state = self.state.read();
918        let tags = state
919            .accounts
920            .get(DEFAULT_ACCOUNT)
921            .and_then(|a| a.tags.get(&arn))
922            .cloned()
923            .unwrap_or_default();
924        drop(state);
925        let body = build_tags_xml(&tags);
926        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
927    }
928}
929
930// ─── Aliases / WebACL ─────────────────────────────────────────────────
931
932impl CloudFrontService {
933    fn associate_alias(
934        &self,
935        req: &AwsRequest,
936        route: &Route,
937    ) -> Result<AwsResponse, AwsServiceError> {
938        let id = route
939            .id
940            .as_deref()
941            .ok_or_else(|| invalid_argument("missing distribution id"))?;
942        let alias = parse_query_value(&req.raw_query, "Alias")
943            .ok_or_else(|| invalid_argument("Alias query parameter is required"))?;
944        let mut state = self.state.write();
945        let account = state
946            .accounts
947            .get_mut(DEFAULT_ACCOUNT)
948            .ok_or_else(|| no_such_distribution(id))?;
949        // Reject if the alias is already attached to a different distribution.
950        if let Some(other) = account.distributions.values().find(|d| {
951            d.id != id
952                && d.config
953                    .aliases
954                    .as_ref()
955                    .and_then(|a| a.items.as_ref())
956                    .is_some_and(|i| i.cname.iter().any(|c| c == &alias))
957        }) {
958            return Err(aws_error(
959                StatusCode::CONFLICT,
960                "CNAMEAlreadyExists",
961                format!(
962                    "Alias {alias} is already associated with distribution {}",
963                    other.id
964                ),
965            ));
966        }
967        let dist = account
968            .distributions
969            .get_mut(id)
970            .ok_or_else(|| no_such_distribution(id))?;
971        let aliases = dist.config.aliases.get_or_insert_with(Default::default);
972        let items = aliases
973            .items
974            .get_or_insert_with(crate::model::AliasItems::default);
975        if !items.cname.iter().any(|c| c == &alias) {
976            items.cname.push(alias.clone());
977            aliases.quantity = items.cname.len() as i32;
978        }
979        dist.etag = generate_etag();
980        dist.last_modified_time = Utc::now();
981        Ok(empty_response(StatusCode::OK))
982    }
983
984    fn list_conflicting_aliases(&self, _req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
985        // We never produce conflicts because every alias is owned by one
986        // distribution at most. Return an empty list with the proper shape.
987        let body = format!(
988            "{XML_DECL}<ConflictingAliasesList xmlns=\"{NS}\"><Quantity>0</Quantity></ConflictingAliasesList>",
989            NS = crate::NAMESPACE,
990            XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
991        );
992        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
993    }
994
995    fn associate_web_acl(
996        &self,
997        req: &AwsRequest,
998        route: &Route,
999    ) -> Result<AwsResponse, AwsServiceError> {
1000        let id = route
1001            .id
1002            .as_deref()
1003            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1004        let parsed: AssociateAliasRequest = xml_io::from_xml_root(&req.body)
1005            .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
1006        let mut state = self.state.write();
1007        let account = state
1008            .accounts
1009            .get_mut(DEFAULT_ACCOUNT)
1010            .ok_or_else(|| no_such_distribution(id))?;
1011        let dist = account
1012            .distributions
1013            .get_mut(id)
1014            .ok_or_else(|| no_such_distribution(id))?;
1015        dist.config.web_acl_id = Some(parsed.web_acl_arn.clone());
1016        dist.etag = generate_etag();
1017        dist.last_modified_time = Utc::now();
1018        let body = format!(
1019            "{XML_DECL}<AssociateDistributionWebACLResult xmlns=\"{NS}\"><Id>{}</Id><WebACLArn>{}</WebACLArn></AssociateDistributionWebACLResult>",
1020            esc(id), esc(&parsed.web_acl_arn),
1021            NS = crate::NAMESPACE,
1022            XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
1023        );
1024        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1025    }
1026
1027    fn disassociate_web_acl(
1028        &self,
1029        _req: &AwsRequest,
1030        route: &Route,
1031    ) -> Result<AwsResponse, AwsServiceError> {
1032        let id = route
1033            .id
1034            .as_deref()
1035            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1036        let mut state = self.state.write();
1037        let account = state
1038            .accounts
1039            .get_mut(DEFAULT_ACCOUNT)
1040            .ok_or_else(|| no_such_distribution(id))?;
1041        let dist = account
1042            .distributions
1043            .get_mut(id)
1044            .ok_or_else(|| no_such_distribution(id))?;
1045        dist.config.web_acl_id = None;
1046        dist.etag = generate_etag();
1047        dist.last_modified_time = Utc::now();
1048        let body = format!(
1049            "{XML_DECL}<DisassociateDistributionWebACLResult xmlns=\"{NS}\"><Id>{}</Id></DisassociateDistributionWebACLResult>",
1050            esc(id),
1051            NS = crate::NAMESPACE,
1052            XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
1053        );
1054        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1055    }
1056}
1057
1058#[derive(serde::Deserialize, Default, Debug)]
1059#[serde(rename_all = "PascalCase")]
1060struct AssociateAliasRequest {
1061    #[serde(rename = "WebACLArn", default)]
1062    web_acl_arn: String,
1063}
1064
1065// ─── XML body builders ────────────────────────────────────────────────
1066
1067/// XML-escape a user-provided string before injecting it into hand-rolled
1068/// XML response bodies. The 5 standard XML metacharacters are escaped;
1069/// everything else passes through unchanged. Keep this in sync with
1070/// `quick_xml`'s entity table — using their own primitive would mean a
1071/// `Writer` per call and we serialize directly into a `String`.
1072pub(crate) fn esc(s: &str) -> String {
1073    let mut out = String::with_capacity(s.len());
1074    for c in s.chars() {
1075        match c {
1076            '&' => out.push_str("&amp;"),
1077            '<' => out.push_str("&lt;"),
1078            '>' => out.push_str("&gt;"),
1079            '"' => out.push_str("&quot;"),
1080            '\'' => out.push_str("&apos;"),
1081            _ => out.push(c),
1082        }
1083    }
1084    out
1085}
1086
1087pub(crate) fn build_distribution_xml(dist: &StoredDistribution) -> String {
1088    let mut out = String::with_capacity(2048);
1089    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1090    out.push_str(&format!(
1091        "<Distribution xmlns=\"{ns}\">",
1092        ns = crate::NAMESPACE
1093    ));
1094    out.push_str(&format!("<Id>{}</Id>", esc(&dist.id)));
1095    out.push_str(&format!("<ARN>{}</ARN>", esc(&dist.arn)));
1096    out.push_str(&format!("<Status>{}</Status>", esc(&dist.status)));
1097    out.push_str(&format!(
1098        "<LastModifiedTime>{}</LastModifiedTime>",
1099        rfc3339(&dist.last_modified_time)
1100    ));
1101    out.push_str(&format!(
1102        "<InProgressInvalidationBatches>{}</InProgressInvalidationBatches>",
1103        dist.in_progress_invalidation_batches
1104    ));
1105    out.push_str(&format!(
1106        "<DomainName>{}</DomainName>",
1107        esc(&dist.domain_name)
1108    ));
1109    out.push_str("<ActiveTrustedSigners><Enabled>false</Enabled><Quantity>0</Quantity></ActiveTrustedSigners>");
1110    out.push_str("<ActiveTrustedKeyGroups><Enabled>false</Enabled><Quantity>0</Quantity></ActiveTrustedKeyGroups>");
1111    let inner = quick_xml::se::to_string_with_root("DistributionConfig", &dist.config)
1112        .unwrap_or_else(|_| String::new());
1113    out.push_str(&inner);
1114    out.push_str("</Distribution>");
1115    out
1116}
1117
1118fn build_distribution_list_xml(dists: &[StoredDistribution], root: &str) -> String {
1119    let mut out = String::with_capacity(2048);
1120    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1121    out.push_str(&format!("<{root} xmlns=\"{ns}\">", ns = crate::NAMESPACE));
1122    out.push_str("<Marker></Marker>");
1123    out.push_str(&format!("<MaxItems>{}</MaxItems>", dists.len().max(100)));
1124    out.push_str("<IsTruncated>false</IsTruncated>");
1125    out.push_str(&format!("<Quantity>{}</Quantity>", dists.len()));
1126    if dists.is_empty() {
1127        out.push_str(&format!("</{root}>"));
1128        return out;
1129    }
1130    out.push_str("<Items>");
1131    for d in dists {
1132        out.push_str("<DistributionSummary>");
1133        out.push_str(&format!("<Id>{}</Id>", esc(&d.id)));
1134        out.push_str(&format!("<ARN>{}</ARN>", esc(&d.arn)));
1135        out.push_str(&format!("<Status>{}</Status>", esc(&d.status)));
1136        out.push_str(&format!(
1137            "<LastModifiedTime>{}</LastModifiedTime>",
1138            rfc3339(&d.last_modified_time)
1139        ));
1140        out.push_str(&format!("<DomainName>{}</DomainName>", esc(&d.domain_name)));
1141        let aliases = d.config.aliases.clone().unwrap_or_default();
1142        out.push_str(&render_inline("Aliases", &aliases));
1143        let origins = d.config.origins.clone();
1144        out.push_str(&render_inline("Origins", &origins));
1145        let dcb = d.config.default_cache_behavior.clone();
1146        out.push_str(&render_inline("DefaultCacheBehavior", &dcb));
1147        let cb = d.config.cache_behaviors.clone().unwrap_or_default();
1148        out.push_str(&render_inline("CacheBehaviors", &cb));
1149        let cer = d.config.custom_error_responses.clone().unwrap_or_default();
1150        out.push_str(&render_inline("CustomErrorResponses", &cer));
1151        out.push_str(&format!("<Comment>{}</Comment>", esc(&d.config.comment)));
1152        out.push_str(&format!(
1153            "<PriceClass>{}</PriceClass>",
1154            esc(&d
1155                .config
1156                .price_class
1157                .clone()
1158                .unwrap_or_else(|| "PriceClass_All".to_string()))
1159        ));
1160        out.push_str(&format!("<Enabled>{}</Enabled>", d.config.enabled));
1161        out.push_str(&render_inline(
1162            "ViewerCertificate",
1163            &d.config.viewer_certificate.clone().unwrap_or_default(),
1164        ));
1165        out.push_str(&render_inline(
1166            "Restrictions",
1167            &d.config.restrictions.clone().unwrap_or_default(),
1168        ));
1169        out.push_str(&format!(
1170            "<WebACLId>{}</WebACLId>",
1171            esc(&d.config.web_acl_id.clone().unwrap_or_default())
1172        ));
1173        out.push_str(&format!(
1174            "<HttpVersion>{}</HttpVersion>",
1175            esc(&d
1176                .config
1177                .http_version
1178                .clone()
1179                .unwrap_or_else(|| "http2".to_string()))
1180        ));
1181        out.push_str(&format!(
1182            "<IsIPV6Enabled>{}</IsIPV6Enabled>",
1183            d.config.is_ipv6_enabled.unwrap_or(true)
1184        ));
1185        out.push_str("<Staging>false</Staging>");
1186        out.push_str("</DistributionSummary>");
1187    }
1188    out.push_str("</Items>");
1189    out.push_str(&format!("</{root}>"));
1190    out
1191}
1192
1193fn build_empty_distribution_id_list(root: &str) -> String {
1194    let mut out = String::with_capacity(256);
1195    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1196    out.push_str(&format!("<{root} xmlns=\"{ns}\">", ns = crate::NAMESPACE));
1197    out.push_str("<Marker></Marker>");
1198    out.push_str("<MaxItems>100</MaxItems>");
1199    out.push_str("<IsTruncated>false</IsTruncated>");
1200    out.push_str("<Quantity>0</Quantity>");
1201    out.push_str(&format!("</{root}>"));
1202    out
1203}
1204
1205fn build_invalidation_xml(inv: &StoredInvalidation) -> String {
1206    let mut out = String::with_capacity(512);
1207    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1208    out.push_str(&format!(
1209        "<Invalidation xmlns=\"{ns}\">",
1210        ns = crate::NAMESPACE
1211    ));
1212    out.push_str(&format!("<Id>{}</Id>", esc(&inv.id)));
1213    out.push_str(&format!("<Status>{}</Status>", esc(&inv.status)));
1214    out.push_str(&format!(
1215        "<CreateTime>{}</CreateTime>",
1216        rfc3339(&inv.create_time)
1217    ));
1218    out.push_str(&render_inline("InvalidationBatch", &inv.batch));
1219    out.push_str("</Invalidation>");
1220    out
1221}
1222
1223fn build_invalidation_list_xml(items: &[&StoredInvalidation]) -> String {
1224    let mut out = String::with_capacity(1024);
1225    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1226    out.push_str(&format!(
1227        "<InvalidationList xmlns=\"{ns}\">",
1228        ns = crate::NAMESPACE
1229    ));
1230    out.push_str("<Marker></Marker>");
1231    out.push_str("<MaxItems>100</MaxItems>");
1232    out.push_str("<IsTruncated>false</IsTruncated>");
1233    out.push_str(&format!("<Quantity>{}</Quantity>", items.len()));
1234    if !items.is_empty() {
1235        out.push_str("<Items>");
1236        for inv in items {
1237            out.push_str("<InvalidationSummary>");
1238            out.push_str(&format!("<Id>{}</Id>", esc(&inv.id)));
1239            out.push_str(&format!(
1240                "<CreateTime>{}</CreateTime>",
1241                rfc3339(&inv.create_time)
1242            ));
1243            out.push_str(&format!("<Status>{}</Status>", esc(&inv.status)));
1244            out.push_str("</InvalidationSummary>");
1245        }
1246        out.push_str("</Items>");
1247    }
1248    out.push_str("</InvalidationList>");
1249    out
1250}
1251
1252fn build_tags_xml(tags: &[Tag]) -> String {
1253    let mut out = String::with_capacity(256);
1254    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1255    out.push_str(&format!("<Tags xmlns=\"{ns}\">", ns = crate::NAMESPACE));
1256    out.push_str("<Items>");
1257    for t in tags {
1258        out.push_str("<Tag>");
1259        out.push_str(&format!("<Key>{}</Key>", esc(&t.key)));
1260        if let Some(v) = &t.value {
1261            out.push_str(&format!("<Value>{}</Value>", esc(v)));
1262        }
1263        out.push_str("</Tag>");
1264    }
1265    out.push_str("</Items>");
1266    out.push_str("</Tags>");
1267    out
1268}
1269
1270fn render_inline<T: serde::Serialize>(root: &str, value: &T) -> String {
1271    quick_xml::se::to_string_with_root(root, value).unwrap_or_default()
1272}
1273
1274// ─── Helpers ──────────────────────────────────────────────────────────
1275
1276fn validate_caller_reference(s: &str) -> Result<(), AwsServiceError> {
1277    if s.is_empty() {
1278        return Err(invalid_argument("CallerReference is required"));
1279    }
1280    Ok(())
1281}
1282
1283fn validate_origins(config: &DistributionConfig) -> Result<(), AwsServiceError> {
1284    if config.origins.quantity < 1 {
1285        return Err(invalid_argument(
1286            "DistributionConfig.Origins must contain at least one origin",
1287        ));
1288    }
1289    Ok(())
1290}
1291
1292fn account_id(_req: &AwsRequest) -> &'static str {
1293    // Multi-account is wired through AwsRequest.account_id elsewhere; the
1294    // CloudFront control plane only uses the resolved id for the ARN
1295    // suffix. Until that field stabilizes for REST-XML we use the default
1296    // account ID consistently with the rest of the registered services.
1297    DEFAULT_ACCOUNT
1298}
1299
1300fn generate_distribution_id() -> String {
1301    // CloudFront IDs are 14-char base32-ish uppercase strings starting with E.
1302    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
1303    format!("E{}", &raw[..13])
1304}
1305
1306fn generate_invalidation_id() -> String {
1307    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
1308    format!("I{}", &raw[..13])
1309}
1310
1311pub(crate) fn generate_etag() -> String {
1312    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
1313    format!("E{}", &raw[..13])
1314}
1315
1316/// Generate an AWS-shaped CloudFront resource ID with the given prefix.
1317/// Used by Batch 2 policy resources (cache, origin request, response
1318/// headers, continuous deployment, OAC) so each gets a recognizable
1319/// alphabetic prefix in addition to the random suffix.
1320pub(crate) fn generate_id_with_prefix(prefix: &str) -> String {
1321    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
1322    let suffix_len = 14usize.saturating_sub(prefix.len()).min(raw.len());
1323    format!("{prefix}{}", &raw[..suffix_len])
1324}
1325
1326fn rfc3339(t: &chrono::DateTime<chrono::Utc>) -> String {
1327    t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
1328}
1329
1330pub(crate) fn invalid_argument(msg: impl Into<String>) -> AwsServiceError {
1331    aws_error(StatusCode::BAD_REQUEST, "InvalidArgument", msg)
1332}
1333
1334fn no_such_distribution(id: &str) -> AwsServiceError {
1335    aws_error(
1336        StatusCode::NOT_FOUND,
1337        "NoSuchDistribution",
1338        format!("The specified distribution does not exist: {id}"),
1339    )
1340}
1341
1342fn no_such_invalidation(id: &str) -> AwsServiceError {
1343    aws_error(
1344        StatusCode::NOT_FOUND,
1345        "NoSuchInvalidation",
1346        format!("The specified invalidation does not exist: {id}"),
1347    )
1348}
1349
1350fn internal_error(msg: impl Into<String>) -> AwsServiceError {
1351    aws_error(StatusCode::INTERNAL_SERVER_ERROR, "InternalError", msg)
1352}
1353
1354pub(crate) fn aws_error(
1355    status: StatusCode,
1356    code: impl Into<String>,
1357    msg: impl Into<String>,
1358) -> AwsServiceError {
1359    AwsServiceError::aws_error(status, code.into(), msg)
1360}
1361
1362fn set_header(headers: &mut HeaderMap, name: HeaderName, value: &str) {
1363    if let Ok(v) = HeaderValue::from_str(value) {
1364        headers.insert(name, v);
1365    }
1366}
1367
1368pub(crate) fn xml_response(status: StatusCode, body: String, headers: HeaderMap) -> AwsResponse {
1369    AwsResponse {
1370        status,
1371        content_type: "text/xml".to_string(),
1372        body: ResponseBody::Bytes(Bytes::from(body)),
1373        headers,
1374    }
1375}
1376
1377fn empty_response(status: StatusCode) -> AwsResponse {
1378    AwsResponse {
1379        status,
1380        content_type: "text/xml".to_string(),
1381        body: ResponseBody::Bytes(Bytes::new()),
1382        headers: HeaderMap::new(),
1383    }
1384}
1385
1386fn percent_decode(input: &str) -> String {
1387    let mut out = String::with_capacity(input.len());
1388    let bytes = input.as_bytes();
1389    let mut i = 0;
1390    while i < bytes.len() {
1391        let b = bytes[i];
1392        if b == b'%' && i + 2 < bytes.len() {
1393            if let (Some(a), Some(c)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
1394                out.push(((a << 4) | c) as char);
1395                i += 3;
1396                continue;
1397            }
1398        }
1399        if b == b'+' {
1400            out.push(' ');
1401        } else {
1402            out.push(b as char);
1403        }
1404        i += 1;
1405    }
1406    out
1407}
1408
1409fn hex_digit(b: u8) -> Option<u8> {
1410    match b {
1411        b'0'..=b'9' => Some(b - b'0'),
1412        b'a'..=b'f' => Some(b - b'a' + 10),
1413        b'A'..=b'F' => Some(b - b'A' + 10),
1414        _ => None,
1415    }
1416}
1417
1418fn parse_query_value(query: &str, key: &str) -> Option<String> {
1419    let prefix = format!("{key}=");
1420    for pair in query.split('&').filter(|p| !p.is_empty()) {
1421        if let Some(rest) = pair.strip_prefix(&prefix) {
1422            return Some(percent_decode(rest));
1423        }
1424    }
1425    None
1426}
1427
1428#[cfg(test)]
1429mod tests {
1430    use super::*;
1431
1432    fn make_state() -> SharedCloudFrontState {
1433        Arc::new(RwLock::new(CloudFrontAccounts::new()))
1434    }
1435
1436    fn make_request(method: http::Method, path: &str, query: &str, body: &str) -> AwsRequest {
1437        AwsRequest {
1438            service: "cloudfront".into(),
1439            action: String::new(),
1440            region: "us-east-1".into(),
1441            account_id: DEFAULT_ACCOUNT.into(),
1442            request_id: Uuid::new_v4().to_string(),
1443            headers: HeaderMap::new(),
1444            query_params: std::collections::HashMap::new(),
1445            body_stream: parking_lot::Mutex::new(None),
1446            body: Bytes::from(body.to_string()),
1447            path_segments: path
1448                .split('/')
1449                .filter(|s| !s.is_empty())
1450                .map(String::from)
1451                .collect(),
1452            raw_path: path.into(),
1453            raw_query: query.into(),
1454            method,
1455            is_query_protocol: false,
1456            access_key_id: None,
1457            principal: None,
1458        }
1459    }
1460
1461    fn minimal_dist_config_xml(caller_ref: &str) -> String {
1462        format!(
1463            r#"<?xml version="1.0" encoding="UTF-8"?>
1464<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
1465  <CallerReference>{caller_ref}</CallerReference>
1466  <Origins>
1467    <Quantity>1</Quantity>
1468    <Items>
1469      <Origin>
1470        <Id>primary</Id>
1471        <DomainName>example.com</DomainName>
1472      </Origin>
1473    </Items>
1474  </Origins>
1475  <DefaultCacheBehavior>
1476    <TargetOriginId>primary</TargetOriginId>
1477    <ViewerProtocolPolicy>allow-all</ViewerProtocolPolicy>
1478  </DefaultCacheBehavior>
1479  <Comment></Comment>
1480  <Enabled>true</Enabled>
1481</DistributionConfig>"#
1482        )
1483    }
1484
1485    #[tokio::test]
1486    async fn create_then_get_then_delete_distribution() {
1487        let svc = CloudFrontService::new(make_state());
1488        let body = minimal_dist_config_xml("ref-1");
1489        let create = svc
1490            .handle(make_request(
1491                http::Method::POST,
1492                "/2020-05-31/distribution",
1493                "",
1494                &body,
1495            ))
1496            .await
1497            .unwrap();
1498        assert_eq!(create.status, StatusCode::CREATED);
1499        let etag = create
1500            .headers
1501            .get(ETAG)
1502            .unwrap()
1503            .to_str()
1504            .unwrap()
1505            .to_string();
1506        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
1507        let id = xml
1508            .split("<Id>")
1509            .nth(1)
1510            .unwrap()
1511            .split("</Id>")
1512            .next()
1513            .unwrap()
1514            .to_string();
1515
1516        let get = svc
1517            .handle(make_request(
1518                http::Method::GET,
1519                &format!("/2020-05-31/distribution/{id}"),
1520                "",
1521                "",
1522            ))
1523            .await
1524            .unwrap();
1525        assert_eq!(get.status, StatusCode::OK);
1526
1527        // Disable then delete (CloudFront requires Disabled before delete).
1528        let disable_body = body.replace("<Enabled>true</Enabled>", "<Enabled>false</Enabled>");
1529        let mut update_req = make_request(
1530            http::Method::PUT,
1531            &format!("/2020-05-31/distribution/{id}/config"),
1532            "",
1533            &disable_body,
1534        );
1535        update_req.headers.insert(IF_MATCH, etag.parse().unwrap());
1536        let updated = svc.handle(update_req).await.unwrap();
1537        assert_eq!(updated.status, StatusCode::OK);
1538        let new_etag = updated
1539            .headers
1540            .get(ETAG)
1541            .unwrap()
1542            .to_str()
1543            .unwrap()
1544            .to_string();
1545
1546        let mut del_req = make_request(
1547            http::Method::DELETE,
1548            &format!("/2020-05-31/distribution/{id}"),
1549            "",
1550            "",
1551        );
1552        del_req.headers.insert(IF_MATCH, new_etag.parse().unwrap());
1553        let del = svc.handle(del_req).await.unwrap();
1554        assert_eq!(del.status, StatusCode::NO_CONTENT);
1555    }
1556
1557    #[tokio::test]
1558    async fn duplicate_caller_reference_is_rejected() {
1559        let svc = CloudFrontService::new(make_state());
1560        let body = minimal_dist_config_xml("dup-ref");
1561        svc.handle(make_request(
1562            http::Method::POST,
1563            "/2020-05-31/distribution",
1564            "",
1565            &body,
1566        ))
1567        .await
1568        .unwrap();
1569        let result = svc
1570            .handle(make_request(
1571                http::Method::POST,
1572                "/2020-05-31/distribution",
1573                "",
1574                &body,
1575            ))
1576            .await;
1577        let err = match result {
1578            Ok(_) => panic!("expected duplicate caller-reference to fail"),
1579            Err(e) => e,
1580        };
1581        assert_eq!(err.code(), "DistributionAlreadyExists");
1582        assert_eq!(err.status(), StatusCode::CONFLICT);
1583    }
1584
1585    #[tokio::test]
1586    async fn invalidation_lifecycle() {
1587        let svc = CloudFrontService::new(make_state());
1588        let body = minimal_dist_config_xml("inv-ref");
1589        let create = svc
1590            .handle(make_request(
1591                http::Method::POST,
1592                "/2020-05-31/distribution",
1593                "",
1594                &body,
1595            ))
1596            .await
1597            .unwrap();
1598        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
1599        let dist_id = xml
1600            .split("<Id>")
1601            .nth(1)
1602            .unwrap()
1603            .split("</Id>")
1604            .next()
1605            .unwrap();
1606        let inv_body = r#"<?xml version="1.0" encoding="UTF-8"?>
1607<InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
1608  <Paths><Quantity>1</Quantity><Items><Path>/*</Path></Items></Paths>
1609  <CallerReference>inv-1</CallerReference>
1610</InvalidationBatch>"#;
1611        let inv_resp = svc
1612            .handle(make_request(
1613                http::Method::POST,
1614                &format!("/2020-05-31/distribution/{dist_id}/invalidation"),
1615                "",
1616                inv_body,
1617            ))
1618            .await
1619            .unwrap();
1620        assert_eq!(inv_resp.status, StatusCode::CREATED);
1621        let inv_xml = std::str::from_utf8(inv_resp.body.expect_bytes()).unwrap();
1622        let inv_id = inv_xml
1623            .split("<Id>")
1624            .nth(1)
1625            .unwrap()
1626            .split("</Id>")
1627            .next()
1628            .unwrap();
1629        let get = svc
1630            .handle(make_request(
1631                http::Method::GET,
1632                &format!("/2020-05-31/distribution/{dist_id}/invalidation/{inv_id}"),
1633                "",
1634                "",
1635            ))
1636            .await
1637            .unwrap();
1638        assert_eq!(get.status, StatusCode::OK);
1639        let list = svc
1640            .handle(make_request(
1641                http::Method::GET,
1642                &format!("/2020-05-31/distribution/{dist_id}/invalidation"),
1643                "",
1644                "",
1645            ))
1646            .await
1647            .unwrap();
1648        let xml = std::str::from_utf8(list.body.expect_bytes()).unwrap();
1649        assert!(xml.contains("<Quantity>1</Quantity>"));
1650    }
1651
1652    #[tokio::test]
1653    async fn tags_roundtrip() {
1654        let svc = CloudFrontService::new(make_state());
1655        let body = minimal_dist_config_xml("tag-ref");
1656        let create = svc
1657            .handle(make_request(
1658                http::Method::POST,
1659                "/2020-05-31/distribution",
1660                "",
1661                &body,
1662            ))
1663            .await
1664            .unwrap();
1665        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
1666        let arn = xml
1667            .split("<ARN>")
1668            .nth(1)
1669            .unwrap()
1670            .split("</ARN>")
1671            .next()
1672            .unwrap();
1673        let tag_body = r#"<?xml version="1.0" encoding="UTF-8"?>
1674<Tags xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
1675  <Items><Tag><Key>env</Key><Value>prod</Value></Tag></Items>
1676</Tags>"#;
1677        let arn_q = format!("Operation=Tag&Resource={}", arn);
1678        let resp = svc
1679            .handle(make_request(
1680                http::Method::POST,
1681                "/2020-05-31/tagging",
1682                &arn_q,
1683                tag_body,
1684            ))
1685            .await
1686            .unwrap();
1687        assert_eq!(resp.status, StatusCode::NO_CONTENT);
1688        let list = svc
1689            .handle(make_request(
1690                http::Method::GET,
1691                "/2020-05-31/tagging",
1692                &format!("Resource={}", arn),
1693                "",
1694            ))
1695            .await
1696            .unwrap();
1697        let xml = std::str::from_utf8(list.body.expect_bytes()).unwrap();
1698        assert!(xml.contains("<Key>env</Key>"));
1699        assert!(xml.contains("<Value>prod</Value>"));
1700    }
1701
1702    #[tokio::test]
1703    async fn xml_metacharacters_in_user_input_are_escaped() {
1704        let svc = CloudFrontService::new(make_state());
1705        let body = minimal_dist_config_xml("escape-ref").replace(
1706            "<Comment></Comment>",
1707            "<Comment><![CDATA[a&b<c>d]]></Comment>",
1708        );
1709        let create = svc
1710            .handle(make_request(
1711                http::Method::POST,
1712                "/2020-05-31/distribution",
1713                "",
1714                &body,
1715            ))
1716            .await
1717            .unwrap();
1718        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
1719        let dist_id = xml
1720            .split("<Id>")
1721            .nth(1)
1722            .unwrap()
1723            .split("</Id>")
1724            .next()
1725            .unwrap();
1726        let arn = xml
1727            .split("<ARN>")
1728            .nth(1)
1729            .unwrap()
1730            .split("</ARN>")
1731            .next()
1732            .unwrap();
1733
1734        let tag_body = r#"<?xml version="1.0" encoding="UTF-8"?>
1735<Tags xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
1736  <Items><Tag><Key>env</Key><Value>a&amp;b&lt;c&gt;d</Value></Tag></Items>
1737</Tags>"#;
1738        let arn_q = format!("Operation=Tag&Resource={}", arn);
1739        svc.handle(make_request(
1740            http::Method::POST,
1741            "/2020-05-31/tagging",
1742            &arn_q,
1743            tag_body,
1744        ))
1745        .await
1746        .unwrap();
1747
1748        let list = svc
1749            .handle(make_request(
1750                http::Method::GET,
1751                "/2020-05-31/tagging",
1752                &format!("Resource={}", arn),
1753                "",
1754            ))
1755            .await
1756            .unwrap();
1757        let xml = std::str::from_utf8(list.body.expect_bytes()).unwrap();
1758        assert!(xml.contains("<Value>a&amp;b&lt;c&gt;d</Value>"));
1759        assert!(!xml.contains("<Value>a&b<c>d</Value>"));
1760
1761        // Force the distribution into the list-rendering path so the
1762        // unescaped Comment field would surface there.
1763        let list_resp = svc
1764            .handle(make_request(
1765                http::Method::GET,
1766                "/2020-05-31/distribution",
1767                "",
1768                "",
1769            ))
1770            .await
1771            .unwrap();
1772        let xml = std::str::from_utf8(list_resp.body.expect_bytes()).unwrap();
1773        // Ensure raw `<` from the user-supplied comment never lands inside
1774        // a Comment element on the wire.
1775        assert!(!xml.contains("<Comment>a&b<c>d"));
1776        assert!(xml.contains(dist_id));
1777    }
1778}