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 tokio::sync::Mutex as AsyncMutex;
12use uuid::Uuid;
13
14use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError, ResponseBody};
15use fakecloud_persistence::SnapshotStore;
16
17use crate::model::{
18    DistributionConfig, DistributionConfigWithTags, InvalidationBatch, TagKeys, Tags as ModelTags,
19};
20use crate::router::{route, Route};
21use crate::state::{
22    CloudFrontAccounts, CloudFrontSnapshot, SharedCloudFrontState, StoredDistribution,
23    StoredInvalidation, Tag, CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION,
24};
25use crate::xml_io;
26
27/// CloudFront mutating actions share these prefixes; everything else
28/// (`Get*`/`List*`/`Describe*`/`Test*`/`Verify*`) is read-only. Used to decide
29/// when a handled request must trigger a persistence snapshot.
30fn is_mutating_action(action: &str) -> bool {
31    const PREFIXES: &[&str] = &[
32        "Create",
33        "Update",
34        "Delete",
35        "Copy",
36        "Associate",
37        "Disassociate",
38        "Tag",
39        "Untag",
40        "Publish",
41        "Put",
42    ];
43    PREFIXES.iter().any(|p| action.starts_with(p))
44}
45
46pub(crate) const DEFAULT_ACCOUNT: &str = "000000000000";
47
48const SUPPORTED_ACTIONS: &[&str] = &[
49    "CreateDistribution",
50    "CreateDistributionWithTags",
51    "GetDistribution",
52    "GetDistributionConfig",
53    "UpdateDistribution",
54    "DeleteDistribution",
55    "ListDistributions",
56    "CopyDistribution",
57    "CreateInvalidation",
58    "GetInvalidation",
59    "ListInvalidations",
60    "TagResource",
61    "UntagResource",
62    "ListTagsForResource",
63    "AssociateAlias",
64    "ListConflictingAliases",
65    "ListDistributionsByCachePolicyId",
66    "ListDistributionsByOriginRequestPolicyId",
67    "ListDistributionsByResponseHeadersPolicyId",
68    "ListDistributionsByKeyGroup",
69    "ListDistributionsByWebACLId",
70    "ListDistributionsByVpcOriginId",
71    "ListDistributionsByAnycastIpListId",
72    "ListDistributionsByConnectionMode",
73    "ListDistributionsByConnectionFunction",
74    "ListDistributionsByOwnedResource",
75    "ListDistributionsByTrustStore",
76    "ListDistributionsByRealtimeLogConfig",
77    "AssociateDistributionWebACL",
78    "DisassociateDistributionWebACL",
79    "CreateOriginAccessControl",
80    "GetOriginAccessControl",
81    "GetOriginAccessControlConfig",
82    "UpdateOriginAccessControl",
83    "DeleteOriginAccessControl",
84    "ListOriginAccessControls",
85    "CreateCachePolicy",
86    "GetCachePolicy",
87    "GetCachePolicyConfig",
88    "UpdateCachePolicy",
89    "DeleteCachePolicy",
90    "ListCachePolicies",
91    "CreateOriginRequestPolicy",
92    "GetOriginRequestPolicy",
93    "GetOriginRequestPolicyConfig",
94    "UpdateOriginRequestPolicy",
95    "DeleteOriginRequestPolicy",
96    "ListOriginRequestPolicies",
97    "CreateResponseHeadersPolicy",
98    "GetResponseHeadersPolicy",
99    "GetResponseHeadersPolicyConfig",
100    "UpdateResponseHeadersPolicy",
101    "DeleteResponseHeadersPolicy",
102    "ListResponseHeadersPolicies",
103    "CreateContinuousDeploymentPolicy",
104    "GetContinuousDeploymentPolicy",
105    "GetContinuousDeploymentPolicyConfig",
106    "UpdateContinuousDeploymentPolicy",
107    "DeleteContinuousDeploymentPolicy",
108    "ListContinuousDeploymentPolicies",
109    "CreateFunction",
110    "DescribeFunction",
111    "GetFunction",
112    "UpdateFunction",
113    "DeleteFunction",
114    "ListFunctions",
115    "PublishFunction",
116    "TestFunction",
117    "CreatePublicKey",
118    "GetPublicKey",
119    "GetPublicKeyConfig",
120    "UpdatePublicKey",
121    "DeletePublicKey",
122    "ListPublicKeys",
123    "CreateKeyGroup",
124    "GetKeyGroup",
125    "GetKeyGroupConfig",
126    "UpdateKeyGroup",
127    "DeleteKeyGroup",
128    "ListKeyGroups",
129    "CreateKeyValueStore",
130    "DescribeKeyValueStore",
131    "UpdateKeyValueStore",
132    "DeleteKeyValueStore",
133    "ListKeyValueStores",
134    "CreateCloudFrontOriginAccessIdentity",
135    "GetCloudFrontOriginAccessIdentity",
136    "GetCloudFrontOriginAccessIdentityConfig",
137    "UpdateCloudFrontOriginAccessIdentity",
138    "DeleteCloudFrontOriginAccessIdentity",
139    "ListCloudFrontOriginAccessIdentities",
140    "CreateMonitoringSubscription",
141    "GetMonitoringSubscription",
142    "DeleteMonitoringSubscription",
143    "CreateStreamingDistribution",
144    "CreateStreamingDistributionWithTags",
145    "GetStreamingDistribution",
146    "GetStreamingDistributionConfig",
147    "UpdateStreamingDistribution",
148    "DeleteStreamingDistribution",
149    "ListStreamingDistributions",
150    "CreateFieldLevelEncryptionConfig",
151    "GetFieldLevelEncryption",
152    "GetFieldLevelEncryptionConfig",
153    "UpdateFieldLevelEncryptionConfig",
154    "DeleteFieldLevelEncryptionConfig",
155    "ListFieldLevelEncryptionConfigs",
156    "CreateFieldLevelEncryptionProfile",
157    "GetFieldLevelEncryptionProfile",
158    "GetFieldLevelEncryptionProfileConfig",
159    "UpdateFieldLevelEncryptionProfile",
160    "DeleteFieldLevelEncryptionProfile",
161    "ListFieldLevelEncryptionProfiles",
162    "CreateRealtimeLogConfig",
163    "GetRealtimeLogConfig",
164    "UpdateRealtimeLogConfig",
165    "DeleteRealtimeLogConfig",
166    "ListRealtimeLogConfigs",
167    "CreateVpcOrigin",
168    "GetVpcOrigin",
169    "UpdateVpcOrigin",
170    "DeleteVpcOrigin",
171    "ListVpcOrigins",
172    "CreateAnycastIpList",
173    "GetAnycastIpList",
174    "UpdateAnycastIpList",
175    "DeleteAnycastIpList",
176    "ListAnycastIpLists",
177    "CreateTrustStore",
178    "GetTrustStore",
179    "UpdateTrustStore",
180    "DeleteTrustStore",
181    "ListTrustStores",
182    "GetResourcePolicy",
183    "PutResourcePolicy",
184    "DeleteResourcePolicy",
185    "CreateConnectionGroup",
186    "GetConnectionGroup",
187    "GetConnectionGroupByRoutingEndpoint",
188    "UpdateConnectionGroup",
189    "DeleteConnectionGroup",
190    "ListConnectionGroups",
191    "ListDomainConflicts",
192    "UpdateDomainAssociation",
193    "VerifyDnsConfiguration",
194    "GetManagedCertificateDetails",
195    "UpdateDistributionWithStagingConfig",
196    "CreateDistributionTenant",
197    "GetDistributionTenant",
198    "GetDistributionTenantByDomain",
199    "UpdateDistributionTenant",
200    "DeleteDistributionTenant",
201    "ListDistributionTenants",
202    "ListDistributionTenantsByCustomization",
203    "AssociateDistributionTenantWebACL",
204    "DisassociateDistributionTenantWebACL",
205    "CreateInvalidationForDistributionTenant",
206    "GetInvalidationForDistributionTenant",
207    "ListInvalidationsForDistributionTenant",
208    "CreateConnectionFunction",
209    "GetConnectionFunction",
210    "DescribeConnectionFunction",
211    "UpdateConnectionFunction",
212    "DeleteConnectionFunction",
213    "ListConnectionFunctions",
214    "PublishConnectionFunction",
215    "TestConnectionFunction",
216];
217
218pub struct CloudFrontService {
219    pub(crate) state: SharedCloudFrontState,
220    /// How long the propagation tick sleeps before flipping a freshly
221    /// created or updated Distribution / DistributionTenant /
222    /// ConnectionGroup / StreamingDistribution from `InProgress` to
223    /// `Deployed`. Real CloudFront takes ~15 minutes; the default of 1s
224    /// keeps SDK-driven integration tests fast while still simulating the
225    /// async transition. Tests can shrink it via
226    /// [`CloudFrontService::with_propagation_delay`], and operators can
227    /// override the default via the
228    /// `FAKECLOUD_CLOUDFRONT_STATUS_DELAY_SEC` env var.
229    pub(crate) propagation_delay: std::time::Duration,
230    pub(crate) snapshot_store: Option<Arc<dyn SnapshotStore>>,
231    pub(crate) snapshot_lock: Arc<AsyncMutex<()>>,
232}
233
234/// Resolve the default `InProgress` -> `Deployed` delay from the
235/// `FAKECLOUD_CLOUDFRONT_STATUS_DELAY_SEC` env var, falling back to 1
236/// second. The value parses as either a non-negative integer (seconds) or
237/// a floating-point number; `0` keeps the transition synchronous, which is
238/// useful for fast unit tests. Invalid input falls back to 1 second so a
239/// typo in the env var can't accidentally hang the process.
240fn default_propagation_delay() -> std::time::Duration {
241    let Ok(raw) = std::env::var("FAKECLOUD_CLOUDFRONT_STATUS_DELAY_SEC") else {
242        return std::time::Duration::from_secs(1);
243    };
244    let trimmed = raw.trim();
245    if let Ok(secs) = trimmed.parse::<u64>() {
246        return std::time::Duration::from_secs(secs);
247    }
248    if let Ok(secs) = trimmed.parse::<f64>() {
249        if secs.is_finite() && secs >= 0.0 {
250            return std::time::Duration::from_secs_f64(secs);
251        }
252    }
253    std::time::Duration::from_secs(1)
254}
255
256impl CloudFrontService {
257    pub fn new(state: SharedCloudFrontState) -> Self {
258        Self {
259            state,
260            propagation_delay: default_propagation_delay(),
261            snapshot_store: None,
262            snapshot_lock: Arc::new(AsyncMutex::new(())),
263        }
264    }
265
266    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
267        self.snapshot_store = Some(store);
268        self
269    }
270
271    pub fn shared_state(&self) -> SharedCloudFrontState {
272        Arc::clone(&self.state)
273    }
274
275    /// Persist current state as a snapshot. Held across the
276    /// clone-serialize-write sequence to prevent stale-last writes, with serde
277    /// + file I/O offloaded to the blocking pool.
278    async fn save_snapshot(&self) {
279        save_cloudfront_snapshot(
280            &self.state,
281            self.snapshot_store.clone(),
282            &self.snapshot_lock,
283        )
284        .await;
285    }
286
287    /// Build a hook that persists the current CloudFront state when invoked, or
288    /// `None` in memory mode. The CloudFormation provisioner mutates `state`
289    /// directly and uses this to write a CFN-provisioned resource through to
290    /// disk, the same way a direct mutating API call would.
291    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
292        let store = self.snapshot_store.clone()?;
293        let state = self.state.clone();
294        let lock = self.snapshot_lock.clone();
295        Some(Arc::new(move || {
296            let state = state.clone();
297            let store = store.clone();
298            let lock = lock.clone();
299            Box::pin(async move {
300                save_cloudfront_snapshot(&state, Some(store), &lock).await;
301            })
302        }))
303    }
304
305    /// Re-arm the propagation tick for any Distribution / DistributionTenant /
306    /// ConnectionGroup / StreamingDistribution that was still `InProgress` when
307    /// the previous process exited, so they still transition to `Deployed`
308    /// after a restart. Called by the server after loading a snapshot.
309    pub fn rearm_in_progress(&self) {
310        let (dists, tenants, groups, streaming) = {
311            let s = self.state.read();
312            let mut dists = Vec::new();
313            let mut tenants = Vec::new();
314            let mut groups = Vec::new();
315            let mut streaming = Vec::new();
316            for account in s.accounts.values() {
317                for (id, d) in &account.distributions {
318                    if d.status == "InProgress" {
319                        dists.push(id.clone());
320                    }
321                }
322                for (id, t) in &account.distribution_tenants {
323                    if t.status == "InProgress" {
324                        tenants.push(id.clone());
325                    }
326                }
327                for (id, g) in &account.connection_groups {
328                    if g.status == "InProgress" {
329                        groups.push(id.clone());
330                    }
331                }
332                for (id, d) in &account.streaming_distributions {
333                    if d.status == "InProgress" {
334                        streaming.push(id.clone());
335                    }
336                }
337            }
338            (dists, tenants, groups, streaming)
339        };
340        for id in dists {
341            self.schedule_distribution_deploy(id);
342        }
343        for id in tenants {
344            self.schedule_distribution_tenant_deploy(id);
345        }
346        for id in groups {
347            self.schedule_connection_group_deploy(id);
348        }
349        for id in streaming {
350            self.schedule_streaming_distribution_deploy(id);
351        }
352    }
353
354    /// Override the propagation delay used by `CreateDistribution`,
355    /// `UpdateDistribution`, and the equivalent DistributionTenant /
356    /// ConnectionGroup ops. Tests pass a small duration so they don't
357    /// have to wait wall-clock seconds for the `InProgress` -> `Deployed`
358    /// transition.
359    pub fn with_propagation_delay(mut self, delay: std::time::Duration) -> Self {
360        self.propagation_delay = delay;
361        self
362    }
363
364    /// Flip a stored Distribution's status synchronously. Returns `false`
365    /// if no distribution matches `id` across any account. The admin
366    /// endpoint `POST /_fakecloud/cloudfront/distributions/{id}/status`
367    /// calls this so tests can force `InProgress` <-> `Deployed` without
368    /// waiting on the propagation tick.
369    pub fn set_distribution_status(&self, id: &str, status: &str) -> bool {
370        let mut state = self.state.write();
371        for account in state.accounts.values_mut() {
372            if let Some(dist) = account.distributions.get_mut(id) {
373                dist.status = status.to_string();
374                return true;
375            }
376        }
377        false
378    }
379
380    /// Admin-endpoint flavour of [`set_distribution_status`](Self::set_distribution_status)
381    /// that persists the forced status so it survives a restart.
382    pub async fn set_distribution_status_persistent(&self, id: &str, status: &str) -> bool {
383        let changed = self.set_distribution_status(id, status);
384        if changed {
385            self.save_snapshot().await;
386        }
387        changed
388    }
389}
390
391/// Persist the current CloudFront state as a snapshot. Offloads the serde +
392/// blocking file write to the Tokio blocking pool. Noop when `store` is `None`
393/// (memory mode). Shared by `CloudFrontService::save_snapshot`, the propagation
394/// ticks, and the CloudFormation provisioner persist hook so all route through
395/// the same serialize-and-write path.
396pub async fn save_cloudfront_snapshot(
397    state: &SharedCloudFrontState,
398    store: Option<Arc<dyn SnapshotStore>>,
399    lock: &AsyncMutex<()>,
400) {
401    let Some(store) = store else {
402        return;
403    };
404    let _guard = lock.lock().await;
405    let snapshot = CloudFrontSnapshot {
406        schema_version: CLOUDFRONT_SNAPSHOT_SCHEMA_VERSION,
407        accounts: Some(state.read().clone()),
408    };
409    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
410        let bytes = serde_json::to_vec(&snapshot)
411            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
412        store.save(&bytes)
413    })
414    .await;
415    match join {
416        Ok(Ok(())) => {}
417        Ok(Err(err)) => tracing::error!(%err, "failed to write cloudfront snapshot"),
418        Err(err) => tracing::error!(%err, "cloudfront snapshot task panicked"),
419    }
420}
421
422impl Default for CloudFrontService {
423    fn default() -> Self {
424        Self::new(Arc::new(RwLock::new(CloudFrontAccounts::new())))
425    }
426}
427
428#[async_trait]
429impl AwsService for CloudFrontService {
430    fn service_name(&self) -> &str {
431        "cloudfront"
432    }
433
434    fn supported_actions(&self) -> &[&str] {
435        SUPPORTED_ACTIONS
436    }
437
438    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
439        let resolved = match route(&req.method, &req.raw_path, &req.raw_query) {
440            Some(r) => r,
441            None => {
442                return Err(aws_error(
443                    StatusCode::NOT_FOUND,
444                    "InvalidArgument",
445                    format!("Unknown CloudFront route: {} {}", req.method, req.raw_path),
446                ));
447            }
448        };
449
450        let mutates = is_mutating_action(resolved.action);
451        let result = match resolved.action {
452            "CreateDistribution" => self.create_distribution(&req, false),
453            "CreateDistributionWithTags" => self.create_distribution(&req, true),
454            "GetDistribution" => self.get_distribution(&resolved),
455            "GetDistributionConfig" => self.get_distribution_config(&resolved),
456            "UpdateDistribution" => self.update_distribution(&req, &resolved),
457            "DeleteDistribution" => self.delete_distribution(&req, &resolved),
458            "ListDistributions" => self.list_distributions(&req),
459            "CopyDistribution" => self.copy_distribution(&req, &resolved),
460            "CreateInvalidation" => self.create_invalidation(&req, &resolved),
461            "GetInvalidation" => self.get_invalidation(&resolved),
462            "ListInvalidations" => self.list_invalidations(&resolved),
463            "TagResource" => self.tag_resource(&req),
464            "UntagResource" => self.untag_resource(&req),
465            "ListTagsForResource" => self.list_tags_for_resource(&req),
466            "AssociateAlias" => self.associate_alias(&req, &resolved),
467            "ListConflictingAliases" => self.list_conflicting_aliases(&req),
468            "AssociateDistributionWebACL" => self.associate_web_acl(&req, &resolved),
469            "DisassociateDistributionWebACL" => self.disassociate_web_acl(&req, &resolved),
470            "ListDistributionsByCachePolicyId"
471            | "ListDistributionsByOriginRequestPolicyId"
472            | "ListDistributionsByResponseHeadersPolicyId"
473            | "ListDistributionsByKeyGroup"
474            | "ListDistributionsByWebACLId"
475            | "ListDistributionsByVpcOriginId"
476            | "ListDistributionsByAnycastIpListId"
477            | "ListDistributionsByConnectionMode"
478            | "ListDistributionsByConnectionFunction"
479            | "ListDistributionsByOwnedResource"
480            | "ListDistributionsByTrustStore"
481            | "ListDistributionsByRealtimeLogConfig" => {
482                self.list_distributions_by(&req, &resolved, resolved.action)
483            }
484            "CreateOriginAccessControl" => self.create_origin_access_control(&req),
485            "GetOriginAccessControl" => self.get_origin_access_control(&resolved),
486            "GetOriginAccessControlConfig" => self.get_origin_access_control_config(&resolved),
487            "UpdateOriginAccessControl" => self.update_origin_access_control(&req, &resolved),
488            "DeleteOriginAccessControl" => self.delete_origin_access_control(&req, &resolved),
489            "ListOriginAccessControls" => self.list_origin_access_controls(&req),
490            "CreateCachePolicy" => self.create_cache_policy(&req),
491            "GetCachePolicy" => self.get_cache_policy(&resolved),
492            "GetCachePolicyConfig" => self.get_cache_policy_config(&resolved),
493            "UpdateCachePolicy" => self.update_cache_policy(&req, &resolved),
494            "DeleteCachePolicy" => self.delete_cache_policy(&req, &resolved),
495            "ListCachePolicies" => self.list_cache_policies(&req),
496            "CreateOriginRequestPolicy" => self.create_origin_request_policy(&req),
497            "GetOriginRequestPolicy" => self.get_origin_request_policy(&resolved),
498            "GetOriginRequestPolicyConfig" => self.get_origin_request_policy_config(&resolved),
499            "UpdateOriginRequestPolicy" => self.update_origin_request_policy(&req, &resolved),
500            "DeleteOriginRequestPolicy" => self.delete_origin_request_policy(&req, &resolved),
501            "ListOriginRequestPolicies" => self.list_origin_request_policies(&req),
502            "CreateResponseHeadersPolicy" => self.create_response_headers_policy(&req),
503            "GetResponseHeadersPolicy" => self.get_response_headers_policy(&resolved),
504            "GetResponseHeadersPolicyConfig" => self.get_response_headers_policy_config(&resolved),
505            "UpdateResponseHeadersPolicy" => self.update_response_headers_policy(&req, &resolved),
506            "DeleteResponseHeadersPolicy" => self.delete_response_headers_policy(&req, &resolved),
507            "ListResponseHeadersPolicies" => self.list_response_headers_policies(&req),
508            "CreateContinuousDeploymentPolicy" => self.create_continuous_deployment_policy(&req),
509            "GetContinuousDeploymentPolicy" => self.get_continuous_deployment_policy(&resolved),
510            "GetContinuousDeploymentPolicyConfig" => {
511                self.get_continuous_deployment_policy_config(&resolved)
512            }
513            "UpdateContinuousDeploymentPolicy" => {
514                self.update_continuous_deployment_policy(&req, &resolved)
515            }
516            "DeleteContinuousDeploymentPolicy" => {
517                self.delete_continuous_deployment_policy(&req, &resolved)
518            }
519            "ListContinuousDeploymentPolicies" => self.list_continuous_deployment_policies(&req),
520            "CreateFunction" => self.create_function(&req),
521            "DescribeFunction" => self.describe_function(&req, &resolved),
522            "GetFunction" => self.get_function(&req, &resolved),
523            "UpdateFunction" => self.update_function(&req, &resolved),
524            "DeleteFunction" => self.delete_function(&req, &resolved),
525            "ListFunctions" => self.list_functions(&req),
526            "PublishFunction" => self.publish_function(&req, &resolved),
527            "TestFunction" => self.test_function(&req, &resolved),
528            "CreatePublicKey" => self.create_public_key(&req),
529            "GetPublicKey" => self.get_public_key(&resolved),
530            "GetPublicKeyConfig" => self.get_public_key_config(&resolved),
531            "UpdatePublicKey" => self.update_public_key(&req, &resolved),
532            "DeletePublicKey" => self.delete_public_key(&req, &resolved),
533            "ListPublicKeys" => self.list_public_keys(&req),
534            "CreateKeyGroup" => self.create_key_group(&req),
535            "GetKeyGroup" => self.get_key_group(&resolved),
536            "GetKeyGroupConfig" => self.get_key_group_config(&resolved),
537            "UpdateKeyGroup" => self.update_key_group(&req, &resolved),
538            "DeleteKeyGroup" => self.delete_key_group(&req, &resolved),
539            "ListKeyGroups" => self.list_key_groups(&req),
540            "CreateKeyValueStore" => self.create_key_value_store(&req),
541            "DescribeKeyValueStore" => self.describe_key_value_store(&resolved),
542            "UpdateKeyValueStore" => self.update_key_value_store(&req, &resolved),
543            "DeleteKeyValueStore" => self.delete_key_value_store(&req, &resolved),
544            "ListKeyValueStores" => self.list_key_value_stores(&req),
545            "CreateCloudFrontOriginAccessIdentity" => self.create_oai(&req),
546            "GetCloudFrontOriginAccessIdentity" => self.get_oai(&resolved),
547            "GetCloudFrontOriginAccessIdentityConfig" => self.get_oai_config(&resolved),
548            "UpdateCloudFrontOriginAccessIdentity" => self.update_oai(&req, &resolved),
549            "DeleteCloudFrontOriginAccessIdentity" => self.delete_oai(&req, &resolved),
550            "ListCloudFrontOriginAccessIdentities" => self.list_oai(&req),
551            "CreateMonitoringSubscription" => self.create_monitoring_subscription(&req, &resolved),
552            "GetMonitoringSubscription" => self.get_monitoring_subscription(&resolved),
553            "DeleteMonitoringSubscription" => self.delete_monitoring_subscription(&resolved),
554            "CreateStreamingDistribution" => self.create_streaming_distribution(&req, false),
555            "CreateStreamingDistributionWithTags" => self.create_streaming_distribution(&req, true),
556            "GetStreamingDistribution" => self.get_streaming_distribution(&resolved),
557            "GetStreamingDistributionConfig" => self.get_streaming_distribution_config(&resolved),
558            "UpdateStreamingDistribution" => self.update_streaming_distribution(&req, &resolved),
559            "DeleteStreamingDistribution" => self.delete_streaming_distribution(&req, &resolved),
560            "ListStreamingDistributions" => self.list_streaming_distributions(&req),
561            "CreateFieldLevelEncryptionConfig" => self.create_field_level_encryption_config(&req),
562            "GetFieldLevelEncryption" => self.get_field_level_encryption(&resolved),
563            "GetFieldLevelEncryptionConfig" => self.get_field_level_encryption_config(&resolved),
564            "UpdateFieldLevelEncryptionConfig" => {
565                self.update_field_level_encryption_config(&req, &resolved)
566            }
567            "DeleteFieldLevelEncryptionConfig" => {
568                self.delete_field_level_encryption_config(&req, &resolved)
569            }
570            "ListFieldLevelEncryptionConfigs" => self.list_field_level_encryption_configs(&req),
571            "CreateFieldLevelEncryptionProfile" => self.create_field_level_encryption_profile(&req),
572            "GetFieldLevelEncryptionProfile" => self.get_field_level_encryption_profile(&resolved),
573            "GetFieldLevelEncryptionProfileConfig" => {
574                self.get_field_level_encryption_profile_config(&resolved)
575            }
576            "UpdateFieldLevelEncryptionProfile" => {
577                self.update_field_level_encryption_profile(&req, &resolved)
578            }
579            "DeleteFieldLevelEncryptionProfile" => {
580                self.delete_field_level_encryption_profile(&req, &resolved)
581            }
582            "ListFieldLevelEncryptionProfiles" => self.list_field_level_encryption_profiles(&req),
583            "CreateRealtimeLogConfig" => self.create_realtime_log_config(&req),
584            "GetRealtimeLogConfig" => self.get_realtime_log_config(&req),
585            "UpdateRealtimeLogConfig" => self.update_realtime_log_config(&req),
586            "DeleteRealtimeLogConfig" => self.delete_realtime_log_config(&req),
587            "ListRealtimeLogConfigs" => self.list_realtime_log_configs(&req),
588            "CreateVpcOrigin" => self.create_vpc_origin(&req),
589            "GetVpcOrigin" => self.get_vpc_origin(&resolved),
590            "UpdateVpcOrigin" => self.update_vpc_origin(&req, &resolved),
591            "DeleteVpcOrigin" => self.delete_vpc_origin(&req, &resolved),
592            "ListVpcOrigins" => self.list_vpc_origins(&req),
593            "CreateAnycastIpList" => self.create_anycast_ip_list(&req),
594            "GetAnycastIpList" => self.get_anycast_ip_list(&resolved),
595            "UpdateAnycastIpList" => self.update_anycast_ip_list(&req, &resolved),
596            "DeleteAnycastIpList" => self.delete_anycast_ip_list(&req, &resolved),
597            "ListAnycastIpLists" => self.list_anycast_ip_lists(&req),
598            "CreateTrustStore" => self.create_trust_store(&req),
599            "GetTrustStore" => self.get_trust_store(&resolved),
600            "UpdateTrustStore" => self.update_trust_store(&req, &resolved),
601            "DeleteTrustStore" => self.delete_trust_store(&req, &resolved),
602            "ListTrustStores" => self.list_trust_stores(&req),
603            "GetResourcePolicy" => self.get_resource_policy(&req),
604            "PutResourcePolicy" => self.put_resource_policy(&req),
605            "DeleteResourcePolicy" => self.delete_resource_policy(&req),
606            "CreateConnectionGroup" => self.create_connection_group(&req),
607            "GetConnectionGroup" => self.get_connection_group(&resolved),
608            "GetConnectionGroupByRoutingEndpoint" => {
609                self.get_connection_group_by_routing_endpoint(&req)
610            }
611            "UpdateConnectionGroup" => self.update_connection_group(&req, &resolved),
612            "DeleteConnectionGroup" => self.delete_connection_group(&req, &resolved),
613            "ListConnectionGroups" => self.list_connection_groups(&req),
614            "ListDomainConflicts" => self.list_domain_conflicts(&req),
615            "UpdateDomainAssociation" => self.update_domain_association(&req),
616            "VerifyDnsConfiguration" => self.verify_dns_configuration(&req),
617            "GetManagedCertificateDetails" => self.get_managed_certificate_details(&resolved),
618            "UpdateDistributionWithStagingConfig" => {
619                self.update_distribution_with_staging_config(&req, &resolved)
620            }
621            "CreateDistributionTenant" => self.create_distribution_tenant(&req),
622            "GetDistributionTenant" => self.get_distribution_tenant(&resolved),
623            "GetDistributionTenantByDomain" => self.get_distribution_tenant_by_domain(&req),
624            "UpdateDistributionTenant" => self.update_distribution_tenant(&req, &resolved),
625            "DeleteDistributionTenant" => self.delete_distribution_tenant(&req, &resolved),
626            "ListDistributionTenants" => self.list_distribution_tenants(&req),
627            "ListDistributionTenantsByCustomization" => {
628                self.list_distribution_tenants_by_customization(&req)
629            }
630            "AssociateDistributionTenantWebACL" => {
631                self.associate_distribution_tenant_web_acl(&req, &resolved)
632            }
633            "DisassociateDistributionTenantWebACL" => {
634                self.disassociate_distribution_tenant_web_acl(&req, &resolved)
635            }
636            "CreateInvalidationForDistributionTenant" => {
637                self.create_invalidation_for_distribution_tenant(&req, &resolved)
638            }
639            "GetInvalidationForDistributionTenant" => {
640                self.get_invalidation_for_distribution_tenant(&resolved)
641            }
642            "ListInvalidationsForDistributionTenant" => {
643                self.list_invalidations_for_distribution_tenant(&resolved)
644            }
645            "CreateConnectionFunction" => self.create_connection_function(&req),
646            "GetConnectionFunction" => self.get_connection_function(&resolved),
647            "DescribeConnectionFunction" => self.describe_connection_function(&resolved),
648            "UpdateConnectionFunction" => self.update_connection_function(&req, &resolved),
649            "DeleteConnectionFunction" => self.delete_connection_function(&req, &resolved),
650            "ListConnectionFunctions" => self.list_connection_functions(&req),
651            "PublishConnectionFunction" => self.publish_connection_function(&req, &resolved),
652            "TestConnectionFunction" => self.test_connection_function(&req, &resolved),
653            other => Err(aws_error(
654                StatusCode::NOT_IMPLEMENTED,
655                "InvalidAction",
656                format!("CloudFront action {other} is not implemented yet"),
657            )),
658        };
659        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
660            self.save_snapshot().await;
661        }
662        result
663    }
664}
665
666// ─── Distribution handlers ────────────────────────────────────────────
667
668impl CloudFrontService {
669    fn create_distribution(
670        &self,
671        req: &AwsRequest,
672        with_tags: bool,
673    ) -> Result<AwsResponse, AwsServiceError> {
674        let (config, tags) = if with_tags {
675            let parsed: DistributionConfigWithTags = xml_io::from_xml_root(&req.body)
676                .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
677            let tags = parsed
678                .tags
679                .items
680                .map(|i| {
681                    i.tag
682                        .into_iter()
683                        .map(|t| Tag {
684                            key: t.key,
685                            value: t.value,
686                        })
687                        .collect()
688                })
689                .unwrap_or_default();
690            (parsed.distribution_config, tags)
691        } else {
692            let parsed: DistributionConfig = xml_io::from_xml_root(&req.body)
693                .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
694            (parsed, Vec::new())
695        };
696
697        validate_caller_reference(&config.caller_reference)?;
698        validate_origins(&config)?;
699
700        let mut state = self.state.write();
701        let account = state.entry(account_id(req));
702
703        if let Some(existing) = account
704            .distributions
705            .values()
706            .find(|d| d.config.caller_reference == config.caller_reference)
707        {
708            return Err(aws_error(
709                StatusCode::CONFLICT,
710                "DistributionAlreadyExists",
711                format!(
712                    "Distribution with the same CallerReference exists: {}",
713                    existing.id
714                ),
715            ));
716        }
717
718        let id = generate_distribution_id();
719        let now = Utc::now();
720        let etag = generate_etag();
721        let domain = format!("{}.cloudfront.net", id.to_lowercase());
722        let arn = format!(
723            "arn:aws:cloudfront::{}:distribution/{}",
724            account_id(req),
725            id
726        );
727
728        let stored = StoredDistribution {
729            id: id.clone(),
730            arn: arn.clone(),
731            // Real CloudFront returns InProgress immediately and flips to
732            // Deployed ~15 minutes later once the edge propagation completes.
733            // The spawn below mirrors that lifecycle on a configurable delay.
734            status: "InProgress".to_string(),
735            last_modified_time: now,
736            domain_name: domain,
737            in_progress_invalidation_batches: 0,
738            etag: etag.clone(),
739            config,
740        };
741        account.distributions.insert(id.clone(), stored.clone());
742        if !tags.is_empty() {
743            account.tags.insert(arn.clone(), tags);
744        }
745        drop(state);
746
747        self.schedule_distribution_deploy(id.clone());
748
749        let body = build_distribution_xml(&stored);
750        let mut headers = HeaderMap::new();
751        set_header(&mut headers, ETAG, &etag);
752        set_header(&mut headers, LOCATION, &stored.arn);
753        Ok(xml_response(StatusCode::CREATED, body, headers))
754    }
755
756    /// Spawn a tokio task that flips the named distribution from
757    /// `InProgress` to `Deployed` after `propagation_delay`. Used by
758    /// `CreateDistribution` and `UpdateDistribution` to model the real
759    /// CloudFront edge propagation lifecycle.
760    fn schedule_distribution_deploy(&self, id: String) {
761        let state = Arc::clone(&self.state);
762        let delay = self.propagation_delay;
763        let store = self.snapshot_store.clone();
764        let lock = self.snapshot_lock.clone();
765        tokio::spawn(async move {
766            tokio::time::sleep(delay).await;
767            let flipped = {
768                let mut s = state.write();
769                let mut flipped = false;
770                for account in s.accounts.values_mut() {
771                    if let Some(d) = account.distributions.get_mut(&id) {
772                        if d.status == "InProgress" {
773                            d.status = "Deployed".to_string();
774                            flipped = true;
775                        }
776                        break;
777                    }
778                }
779                flipped
780            };
781            if flipped {
782                save_cloudfront_snapshot(&state, store, &lock).await;
783            }
784        });
785    }
786
787    /// Same as [`schedule_distribution_deploy`] but for DistributionTenants.
788    pub(crate) fn schedule_distribution_tenant_deploy(&self, id: String) {
789        let state = Arc::clone(&self.state);
790        let delay = self.propagation_delay;
791        let store = self.snapshot_store.clone();
792        let lock = self.snapshot_lock.clone();
793        tokio::spawn(async move {
794            tokio::time::sleep(delay).await;
795            let flipped = {
796                let mut s = state.write();
797                let mut flipped = false;
798                for account in s.accounts.values_mut() {
799                    if let Some(t) = account.distribution_tenants.get_mut(&id) {
800                        if t.status == "InProgress" {
801                            t.status = "Deployed".to_string();
802                            flipped = true;
803                        }
804                        break;
805                    }
806                }
807                flipped
808            };
809            if flipped {
810                save_cloudfront_snapshot(&state, store, &lock).await;
811            }
812        });
813    }
814
815    /// Same as [`schedule_distribution_deploy`] but for ConnectionGroups.
816    pub(crate) fn schedule_connection_group_deploy(&self, id: String) {
817        let state = Arc::clone(&self.state);
818        let delay = self.propagation_delay;
819        let store = self.snapshot_store.clone();
820        let lock = self.snapshot_lock.clone();
821        tokio::spawn(async move {
822            tokio::time::sleep(delay).await;
823            let flipped = {
824                let mut s = state.write();
825                let mut flipped = false;
826                for account in s.accounts.values_mut() {
827                    if let Some(g) = account.connection_groups.get_mut(&id) {
828                        if g.status == "InProgress" {
829                            g.status = "Deployed".to_string();
830                            flipped = true;
831                        }
832                        break;
833                    }
834                }
835                flipped
836            };
837            if flipped {
838                save_cloudfront_snapshot(&state, store, &lock).await;
839            }
840        });
841    }
842
843    /// Same as [`schedule_distribution_deploy`] but for StreamingDistributions.
844    /// Real CloudFront mirrors the `InProgress` -> `Deployed` lifecycle for
845    /// RTMP streaming distributions, even though the resource is largely
846    /// deprecated.
847    pub(crate) fn schedule_streaming_distribution_deploy(&self, id: String) {
848        let state = Arc::clone(&self.state);
849        let delay = self.propagation_delay;
850        let store = self.snapshot_store.clone();
851        let lock = self.snapshot_lock.clone();
852        tokio::spawn(async move {
853            tokio::time::sleep(delay).await;
854            let flipped = {
855                let mut s = state.write();
856                let mut flipped = false;
857                for account in s.accounts.values_mut() {
858                    if let Some(d) = account.streaming_distributions.get_mut(&id) {
859                        if d.status == "InProgress" {
860                            d.status = "Deployed".to_string();
861                            flipped = true;
862                        }
863                        break;
864                    }
865                }
866                flipped
867            };
868            if flipped {
869                save_cloudfront_snapshot(&state, store, &lock).await;
870            }
871        });
872    }
873
874    fn get_distribution(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
875        let id = route
876            .id
877            .as_deref()
878            .ok_or_else(|| invalid_argument("missing distribution id"))?;
879        let state = self.state.read();
880        let account = state
881            .accounts
882            .get(DEFAULT_ACCOUNT)
883            .ok_or_else(|| no_such_distribution(id))?;
884        let dist = account
885            .distributions
886            .get(id)
887            .ok_or_else(|| no_such_distribution(id))?
888            .clone();
889        drop(state);
890        let body = build_distribution_xml(&dist);
891        let mut headers = HeaderMap::new();
892        set_header(&mut headers, ETAG, &dist.etag);
893        Ok(xml_response(StatusCode::OK, body, headers))
894    }
895
896    fn get_distribution_config(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
897        let id = route
898            .id
899            .as_deref()
900            .ok_or_else(|| invalid_argument("missing distribution id"))?;
901        let state = self.state.read();
902        let account = state
903            .accounts
904            .get(DEFAULT_ACCOUNT)
905            .ok_or_else(|| no_such_distribution(id))?;
906        let dist = account
907            .distributions
908            .get(id)
909            .ok_or_else(|| no_such_distribution(id))?
910            .clone();
911        drop(state);
912        let body = xml_io::to_xml_root("DistributionConfig", &dist.config)
913            .map_err(|e| internal_error(format!("xml encode failed: {e}")))?;
914        let mut headers = HeaderMap::new();
915        set_header(&mut headers, ETAG, &dist.etag);
916        Ok(xml_response(StatusCode::OK, body, headers))
917    }
918
919    fn update_distribution(
920        &self,
921        req: &AwsRequest,
922        route: &Route,
923    ) -> Result<AwsResponse, AwsServiceError> {
924        let id = route
925            .id
926            .as_deref()
927            .ok_or_else(|| invalid_argument("missing distribution id"))?;
928        let if_match = req
929            .headers
930            .get(IF_MATCH)
931            .and_then(|v| v.to_str().ok())
932            .ok_or_else(|| {
933                aws_error(
934                    StatusCode::BAD_REQUEST,
935                    "InvalidIfMatchVersion",
936                    "Missing If-Match header for UpdateDistribution",
937                )
938            })?
939            .to_string();
940        let new_config: DistributionConfig = xml_io::from_xml_root(&req.body)
941            .map_err(|e| invalid_argument(format!("invalid DistributionConfig XML: {e}")))?;
942        validate_caller_reference(&new_config.caller_reference)?;
943        validate_origins(&new_config)?;
944
945        let mut state = self.state.write();
946        let account = state
947            .accounts
948            .get_mut(DEFAULT_ACCOUNT)
949            .ok_or_else(|| no_such_distribution(id))?;
950        let dist = account
951            .distributions
952            .get_mut(id)
953            .ok_or_else(|| no_such_distribution(id))?;
954        if dist.etag != if_match {
955            return Err(aws_error(
956                StatusCode::PRECONDITION_FAILED,
957                "PreconditionFailed",
958                "If-Match header does not match the current ETag",
959            ));
960        }
961        // ETag stability: only bump the ETag and flip status back to
962        // InProgress when the new config actually differs from what we
963        // have on disk. A no-op UpdateDistribution (PUT the same config
964        // back) leaves the ETag intact, matching AWS behavior.
965        let config_changed = !configs_equal(&dist.config, &new_config);
966        if config_changed {
967            dist.config = new_config;
968            dist.etag = generate_etag();
969            dist.last_modified_time = Utc::now();
970            // UpdateDistribution kicks off a fresh edge propagation; AWS
971            // flips the status back to InProgress until the new config
972            // lands.
973            dist.status = "InProgress".to_string();
974        }
975        let snapshot = dist.clone();
976        drop(state);
977
978        if config_changed {
979            self.schedule_distribution_deploy(id.to_string());
980        }
981
982        let body = build_distribution_xml(&snapshot);
983        let mut headers = HeaderMap::new();
984        set_header(&mut headers, ETAG, &snapshot.etag);
985        Ok(xml_response(StatusCode::OK, body, headers))
986    }
987
988    fn delete_distribution(
989        &self,
990        req: &AwsRequest,
991        route: &Route,
992    ) -> Result<AwsResponse, AwsServiceError> {
993        let id = route
994            .id
995            .as_deref()
996            .ok_or_else(|| invalid_argument("missing distribution id"))?;
997        let if_match = req
998            .headers
999            .get(IF_MATCH)
1000            .and_then(|v| v.to_str().ok())
1001            .ok_or_else(|| {
1002                aws_error(
1003                    StatusCode::BAD_REQUEST,
1004                    "InvalidIfMatchVersion",
1005                    "Missing If-Match header for DeleteDistribution",
1006                )
1007            })?
1008            .to_string();
1009        let mut state = self.state.write();
1010        let account = state
1011            .accounts
1012            .get_mut(DEFAULT_ACCOUNT)
1013            .ok_or_else(|| no_such_distribution(id))?;
1014        {
1015            let dist = account
1016                .distributions
1017                .get(id)
1018                .ok_or_else(|| no_such_distribution(id))?;
1019            if dist.etag != if_match {
1020                return Err(aws_error(
1021                    StatusCode::PRECONDITION_FAILED,
1022                    "PreconditionFailed",
1023                    "If-Match header does not match the current ETag",
1024                ));
1025            }
1026            if dist.config.enabled {
1027                return Err(aws_error(
1028                    StatusCode::PRECONDITION_FAILED,
1029                    "DistributionNotDisabled",
1030                    "Distribution must be disabled before delete",
1031                ));
1032            }
1033        }
1034        let removed = account.distributions.remove(id).unwrap();
1035        account.tags.remove(&removed.arn);
1036        Ok(empty_response(StatusCode::NO_CONTENT))
1037    }
1038
1039    fn list_distributions(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1040        let state = self.state.read();
1041        let mut dists: Vec<StoredDistribution> = state
1042            .accounts
1043            .values()
1044            .flat_map(|a| a.distributions.values().cloned())
1045            .collect();
1046        drop(state);
1047        dists.sort_by_key(|a| a.last_modified_time);
1048
1049        // CloudFront paginates with an exclusive Marker (start after this id) +
1050        // MaxItems; shared with the ListDistributionsBy* handlers.
1051        let (marker, max_items, page, next_marker) = paginate_distributions(req, dists);
1052
1053        let body = build_distribution_list_xml(
1054            &page,
1055            "DistributionList",
1056            &marker,
1057            max_items,
1058            next_marker.as_deref(),
1059        );
1060        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1061    }
1062
1063    fn list_distributions_by(
1064        &self,
1065        req: &AwsRequest,
1066        route: &Route,
1067        action: &str,
1068    ) -> Result<AwsResponse, AwsServiceError> {
1069        // Each "by-X" listing has a Smithy-required identifier (path or query)
1070        // plus, for ConnectionMode, an enum-constrained discriminator. The
1071        // synthetic probe sends `negative_omit_*` variants without these, so
1072        // every handler that can short-circuit on a missing identifier must
1073        // do so up-front. Otherwise the empty-list 200 looks like an honest
1074        // pass to the probe and a 100% conformance number is unreachable.
1075        match action {
1076            // Path-id ops: route.id is the URL placeholder. If the probe
1077            // omitted the field, the substitution left the literal
1078            // `{Member}` braces in place — easy to detect.
1079            "ListDistributionsByCachePolicyId"
1080            | "ListDistributionsByOriginRequestPolicyId"
1081            | "ListDistributionsByResponseHeadersPolicyId"
1082            | "ListDistributionsByKeyGroup"
1083            | "ListDistributionsByWebACLId"
1084            | "ListDistributionsByVpcOriginId"
1085            | "ListDistributionsByAnycastIpListId"
1086            | "ListDistributionsByOwnedResource" => {
1087                let id = route.id.as_deref().unwrap_or("");
1088                if is_placeholder_label(id) {
1089                    return Err(invalid_argument(format!(
1090                        "Required URL identifier for {action} is missing or invalid"
1091                    )));
1092                }
1093            }
1094            "ListDistributionsByConnectionMode" => {
1095                let id = route.id.as_deref().unwrap_or("");
1096                if is_placeholder_label(id) {
1097                    return Err(invalid_argument(
1098                        "ConnectionMode is required for ListDistributionsByConnectionMode",
1099                    ));
1100                }
1101                if id != "direct" && id != "tenant-only" {
1102                    return Err(invalid_argument(format!(
1103                        "ConnectionMode must be 'direct' or 'tenant-only', got '{id}'"
1104                    )));
1105                }
1106            }
1107            "ListDistributionsByConnectionFunction"
1108                if parse_query_value(&req.raw_query, "ConnectionFunctionIdentifier").is_none() =>
1109            {
1110                return Err(invalid_argument(
1111                    "ConnectionFunctionIdentifier query parameter is required",
1112                ));
1113            }
1114            "ListDistributionsByTrustStore"
1115                if parse_query_value(&req.raw_query, "TrustStoreIdentifier").is_none() =>
1116            {
1117                return Err(invalid_argument(
1118                    "TrustStoreIdentifier query parameter is required",
1119                ));
1120            }
1121            _ => {}
1122        }
1123
1124        // The "by-X" listings each have a distinct response root element.
1125        // For the seven predicate ops whose match field is persisted on the
1126        // distribution config we filter the live state; the remaining ops
1127        // (owned-resource, connection-mode/function, trust-store,
1128        // realtime-log-config) are not yet indexed and stay empty.
1129        let root = match action {
1130            "ListDistributionsByCachePolicyId"
1131            | "ListDistributionsByOriginRequestPolicyId"
1132            | "ListDistributionsByResponseHeadersPolicyId"
1133            | "ListDistributionsByKeyGroup"
1134            | "ListDistributionsByVpcOriginId" => "DistributionIdList",
1135            "ListDistributionsByOwnedResource" => "DistributionIdOwnerList",
1136            _ => "DistributionList",
1137        };
1138
1139        // Collect all distributions once, sorted for stable ordering.
1140        let mut all: Vec<StoredDistribution> = {
1141            let state = self.state.read();
1142            state
1143                .accounts
1144                .values()
1145                .flat_map(|a| a.distributions.values().cloned())
1146                .collect()
1147        };
1148        all.sort_by_key(|d| d.last_modified_time);
1149
1150        let path_id = route.id.as_deref().unwrap_or("");
1151        let matched: Vec<StoredDistribution> = match action {
1152            "ListDistributionsByCachePolicyId" => all
1153                .into_iter()
1154                .filter(|d| distribution_uses_cache_policy(d, path_id))
1155                .collect(),
1156            "ListDistributionsByOriginRequestPolicyId" => all
1157                .into_iter()
1158                .filter(|d| distribution_uses_origin_request_policy(d, path_id))
1159                .collect(),
1160            "ListDistributionsByResponseHeadersPolicyId" => all
1161                .into_iter()
1162                .filter(|d| distribution_uses_response_headers_policy(d, path_id))
1163                .collect(),
1164            "ListDistributionsByKeyGroup" => all
1165                .into_iter()
1166                .filter(|d| distribution_uses_key_group(d, path_id))
1167                .collect(),
1168            "ListDistributionsByVpcOriginId" => all
1169                .into_iter()
1170                .filter(|d| distribution_uses_vpc_origin(d, path_id))
1171                .collect(),
1172            // CloudFront treats the literal WebACLId "null" as "list the
1173            // distributions that aren't associated with any web ACL".
1174            "ListDistributionsByWebACLId" if path_id == "null" => all
1175                .into_iter()
1176                .filter(|d| {
1177                    d.config
1178                        .web_acl_id
1179                        .as_deref()
1180                        .map(|w| w.is_empty())
1181                        .unwrap_or(true)
1182                })
1183                .collect(),
1184            "ListDistributionsByWebACLId" => all
1185                .into_iter()
1186                .filter(|d| d.config.web_acl_id.as_deref() == Some(path_id))
1187                .collect(),
1188            "ListDistributionsByAnycastIpListId" => all
1189                .into_iter()
1190                .filter(|d| d.config.anycast_ip_list_id.as_deref() == Some(path_id))
1191                .collect(),
1192            _ => Vec::new(),
1193        };
1194
1195        // Mirror the Marker/MaxItems pagination the plain ListDistributions
1196        // handler applies so large predicate result sets page correctly.
1197        let (marker, max_items, page, next_marker) = paginate_distributions(req, matched);
1198
1199        let body = if root == "DistributionIdList" {
1200            let ids: Vec<&str> = page.iter().map(|d| d.id.as_str()).collect();
1201            build_distribution_id_list_xml(&ids, &marker, max_items, next_marker.as_deref())
1202        } else if root == "DistributionList"
1203            && matches!(
1204                action,
1205                "ListDistributionsByWebACLId" | "ListDistributionsByAnycastIpListId"
1206            )
1207        {
1208            build_distribution_list_xml(
1209                &page,
1210                "DistributionList",
1211                &marker,
1212                max_items,
1213                next_marker.as_deref(),
1214            )
1215        } else {
1216            build_empty_distribution_id_list(root)
1217        };
1218        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1219    }
1220
1221    fn copy_distribution(
1222        &self,
1223        req: &AwsRequest,
1224        route: &Route,
1225    ) -> Result<AwsResponse, AwsServiceError> {
1226        let primary_id = route
1227            .id
1228            .as_deref()
1229            .ok_or_else(|| invalid_argument("missing primary distribution id"))?;
1230        let if_match = req
1231            .headers
1232            .get(IF_MATCH)
1233            .and_then(|v| v.to_str().ok())
1234            .ok_or_else(|| {
1235                aws_error(
1236                    StatusCode::BAD_REQUEST,
1237                    "InvalidIfMatchVersion",
1238                    "Missing If-Match header for CopyDistribution",
1239                )
1240            })?
1241            .to_string();
1242        let parsed: CopyDistributionRequest = xml_io::from_xml_root(&req.body)
1243            .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
1244        validate_caller_reference(&parsed.caller_reference)?;
1245        let mut state = self.state.write();
1246        let account = state
1247            .accounts
1248            .get_mut(DEFAULT_ACCOUNT)
1249            .ok_or_else(|| no_such_distribution(primary_id))?;
1250        let primary = account
1251            .distributions
1252            .get(primary_id)
1253            .ok_or_else(|| no_such_distribution(primary_id))?
1254            .clone();
1255        if primary.etag != if_match {
1256            return Err(aws_error(
1257                StatusCode::PRECONDITION_FAILED,
1258                "PreconditionFailed",
1259                "If-Match header does not match the current ETag",
1260            ));
1261        }
1262        if account
1263            .distributions
1264            .values()
1265            .any(|d| d.config.caller_reference == parsed.caller_reference)
1266        {
1267            return Err(aws_error(
1268                StatusCode::CONFLICT,
1269                "DistributionAlreadyExists",
1270                "Distribution with the same CallerReference exists",
1271            ));
1272        }
1273        let new_id = generate_distribution_id();
1274        let mut config = primary.config.clone();
1275        config.caller_reference = parsed.caller_reference;
1276        config.enabled = parsed.enabled.unwrap_or(false);
1277        config.staging = parsed.staging;
1278        let now = Utc::now();
1279        let etag = generate_etag();
1280        let arn = format!(
1281            "arn:aws:cloudfront::{}:distribution/{}",
1282            account_id(req),
1283            new_id
1284        );
1285        let stored = StoredDistribution {
1286            id: new_id.clone(),
1287            arn: arn.clone(),
1288            status: "InProgress".to_string(),
1289            last_modified_time: now,
1290            domain_name: format!("{}.cloudfront.net", new_id.to_lowercase()),
1291            in_progress_invalidation_batches: 0,
1292            etag: etag.clone(),
1293            config,
1294        };
1295        account.distributions.insert(new_id.clone(), stored.clone());
1296        drop(state);
1297        self.schedule_distribution_deploy(new_id);
1298        let body = build_distribution_xml(&stored);
1299        let mut headers = HeaderMap::new();
1300        set_header(&mut headers, ETAG, &etag);
1301        set_header(&mut headers, LOCATION, &stored.arn);
1302        Ok(xml_response(StatusCode::CREATED, body, headers))
1303    }
1304}
1305
1306#[derive(Debug, serde::Deserialize, Default)]
1307#[serde(rename_all = "PascalCase")]
1308struct CopyDistributionRequest {
1309    caller_reference: String,
1310    #[serde(default)]
1311    enabled: Option<bool>,
1312    #[serde(default)]
1313    staging: Option<bool>,
1314}
1315
1316// ─── Invalidations ────────────────────────────────────────────────────
1317
1318impl CloudFrontService {
1319    fn create_invalidation(
1320        &self,
1321        req: &AwsRequest,
1322        route: &Route,
1323    ) -> Result<AwsResponse, AwsServiceError> {
1324        let dist_id = route
1325            .id
1326            .as_deref()
1327            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1328        let batch: InvalidationBatch = xml_io::from_xml_root(&req.body)
1329            .map_err(|e| invalid_argument(format!("invalid InvalidationBatch XML: {e}")))?;
1330        if batch.caller_reference.is_empty() {
1331            return Err(invalid_argument("CallerReference is required"));
1332        }
1333        if batch.paths.quantity < 1 {
1334            return Err(invalid_argument(
1335                "InvalidationBatch.Paths must be non-empty",
1336            ));
1337        }
1338        let mut state = self.state.write();
1339        let account = state.entry(DEFAULT_ACCOUNT);
1340        if !account.distributions.contains_key(dist_id) {
1341            return Err(no_such_distribution(dist_id));
1342        }
1343        let id = generate_invalidation_id();
1344        let stored = StoredInvalidation {
1345            id: id.clone(),
1346            distribution_id: dist_id.to_string(),
1347            status: "Completed".to_string(),
1348            create_time: Utc::now(),
1349            batch: batch.clone(),
1350        };
1351        account.invalidations.insert(id.clone(), stored.clone());
1352        drop(state);
1353        let body = build_invalidation_xml(&stored);
1354        let mut headers = HeaderMap::new();
1355        set_header(
1356            &mut headers,
1357            LOCATION,
1358            &format!(
1359                "/2020-05-31/distribution/{dist_id}/invalidation/{}",
1360                stored.id
1361            ),
1362        );
1363        Ok(xml_response(StatusCode::CREATED, body, headers))
1364    }
1365
1366    fn get_invalidation(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
1367        let dist_id = route
1368            .id
1369            .as_deref()
1370            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1371        let inv_id = route
1372            .second_id
1373            .as_deref()
1374            .ok_or_else(|| invalid_argument("missing invalidation id"))?;
1375        let state = self.state.read();
1376        let account = state
1377            .accounts
1378            .get(DEFAULT_ACCOUNT)
1379            .ok_or_else(|| no_such_invalidation(inv_id))?;
1380        if !account.distributions.contains_key(dist_id) {
1381            return Err(no_such_distribution(dist_id));
1382        }
1383        let inv = account
1384            .invalidations
1385            .get(inv_id)
1386            .filter(|i| i.distribution_id == dist_id)
1387            .ok_or_else(|| no_such_invalidation(inv_id))?
1388            .clone();
1389        drop(state);
1390        let body = build_invalidation_xml(&inv);
1391        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1392    }
1393
1394    fn list_invalidations(&self, route: &Route) -> Result<AwsResponse, AwsServiceError> {
1395        let dist_id = route
1396            .id
1397            .as_deref()
1398            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1399        let state = self.state.read();
1400        let account = state
1401            .accounts
1402            .get(DEFAULT_ACCOUNT)
1403            .ok_or_else(|| no_such_distribution(dist_id))?;
1404        if !account.distributions.contains_key(dist_id) {
1405            return Err(no_such_distribution(dist_id));
1406        }
1407        let mut items: Vec<&StoredInvalidation> = account
1408            .invalidations
1409            .values()
1410            .filter(|i| i.distribution_id == dist_id)
1411            .collect();
1412        items.sort_by_key(|a| a.create_time);
1413        let body = build_invalidation_list_xml(&items);
1414        drop(state);
1415        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1416    }
1417}
1418
1419// ─── Tags ─────────────────────────────────────────────────────────────
1420
1421impl CloudFrontService {
1422    fn parse_arn_query(query: &str) -> Option<String> {
1423        for pair in query.split('&').filter(|p| !p.is_empty()) {
1424            if let Some(rest) = pair.strip_prefix("Resource=") {
1425                return Some(percent_decode(rest));
1426            }
1427        }
1428        None
1429    }
1430
1431    fn tag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1432        let arn = Self::parse_arn_query(&req.raw_query)
1433            .ok_or_else(|| invalid_argument("Resource query parameter is required"))?;
1434        let parsed: ModelTags = xml_io::from_xml_root(&req.body)
1435            .map_err(|e| invalid_argument(format!("invalid Tags XML: {e}")))?;
1436        let new_tags: Vec<Tag> = parsed
1437            .items
1438            .map(|i| {
1439                i.tag
1440                    .into_iter()
1441                    .map(|t| Tag {
1442                        key: t.key,
1443                        value: t.value,
1444                    })
1445                    .collect()
1446            })
1447            .unwrap_or_default();
1448        let mut state = self.state.write();
1449        let account = state.entry(DEFAULT_ACCOUNT);
1450        let entry = account.tags.entry(arn).or_default();
1451        for tag in new_tags {
1452            if let Some(existing) = entry.iter_mut().find(|t| t.key == tag.key) {
1453                existing.value = tag.value;
1454            } else {
1455                entry.push(tag);
1456            }
1457        }
1458        Ok(empty_response(StatusCode::NO_CONTENT))
1459    }
1460
1461    fn untag_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1462        let arn = Self::parse_arn_query(&req.raw_query)
1463            .ok_or_else(|| invalid_argument("Resource query parameter is required"))?;
1464        let parsed: TagKeys = xml_io::from_xml_root(&req.body)
1465            .map_err(|e| invalid_argument(format!("invalid TagKeys XML: {e}")))?;
1466        let keys: Vec<String> = parsed.items.map(|k| k.key).unwrap_or_default();
1467        let mut state = self.state.write();
1468        let account = state.entry(DEFAULT_ACCOUNT);
1469        if let Some(existing) = account.tags.get_mut(&arn) {
1470            existing.retain(|t| !keys.contains(&t.key));
1471        }
1472        Ok(empty_response(StatusCode::NO_CONTENT))
1473    }
1474
1475    fn list_tags_for_resource(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1476        let arn = Self::parse_arn_query(&req.raw_query)
1477            .ok_or_else(|| invalid_argument("Resource query parameter is required"))?;
1478        let state = self.state.read();
1479        let tags = state
1480            .accounts
1481            .get(DEFAULT_ACCOUNT)
1482            .and_then(|a| a.tags.get(&arn))
1483            .cloned()
1484            .unwrap_or_default();
1485        drop(state);
1486        let body = build_tags_xml(&tags);
1487        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1488    }
1489}
1490
1491// ─── Aliases / WebACL ─────────────────────────────────────────────────
1492
1493impl CloudFrontService {
1494    fn associate_alias(
1495        &self,
1496        req: &AwsRequest,
1497        route: &Route,
1498    ) -> Result<AwsResponse, AwsServiceError> {
1499        let id = route
1500            .id
1501            .as_deref()
1502            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1503        let alias = parse_query_value(&req.raw_query, "Alias")
1504            .ok_or_else(|| invalid_argument("Alias query parameter is required"))?;
1505        let mut state = self.state.write();
1506        let account = state
1507            .accounts
1508            .get_mut(DEFAULT_ACCOUNT)
1509            .ok_or_else(|| no_such_distribution(id))?;
1510        // Reject if the alias is already attached to a different distribution.
1511        if let Some(other) = account.distributions.values().find(|d| {
1512            d.id != id
1513                && d.config
1514                    .aliases
1515                    .as_ref()
1516                    .and_then(|a| a.items.as_ref())
1517                    .is_some_and(|i| i.cname.iter().any(|c| c == &alias))
1518        }) {
1519            return Err(aws_error(
1520                StatusCode::CONFLICT,
1521                "CNAMEAlreadyExists",
1522                format!(
1523                    "Alias {alias} is already associated with distribution {}",
1524                    other.id
1525                ),
1526            ));
1527        }
1528        let dist = account
1529            .distributions
1530            .get_mut(id)
1531            .ok_or_else(|| no_such_distribution(id))?;
1532        let aliases = dist.config.aliases.get_or_insert_with(Default::default);
1533        let items = aliases
1534            .items
1535            .get_or_insert_with(crate::model::AliasItems::default);
1536        if !items.cname.iter().any(|c| c == &alias) {
1537            items.cname.push(alias.clone());
1538            aliases.quantity = items.cname.len() as i32;
1539        }
1540        dist.etag = generate_etag();
1541        dist.last_modified_time = Utc::now();
1542        Ok(empty_response(StatusCode::OK))
1543    }
1544
1545    fn list_conflicting_aliases(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1546        let alias = parse_query_value(&req.raw_query, "Alias")
1547            .ok_or_else(|| invalid_argument("Alias query parameter is required"))?;
1548        let dist_id = parse_query_value(&req.raw_query, "DistributionId")
1549            .ok_or_else(|| invalid_argument("DistributionId query parameter is required"))?;
1550        // aliasString max 253, distributionIdString max 25 per the Smithy
1551        // model. Reject probe-generated boundary variants that overrun the
1552        // documented length so they produce the declared InvalidArgument
1553        // instead of an empty 200.
1554        if alias.len() > 253 {
1555            return Err(invalid_argument(format!(
1556                "Alias length {} exceeds maximum 253",
1557                alias.len()
1558            )));
1559        }
1560        if dist_id.len() > 25 {
1561            return Err(invalid_argument(format!(
1562                "DistributionId length {} exceeds maximum 25",
1563                dist_id.len()
1564            )));
1565        }
1566        if let Some(max_items) = parse_query_value(&req.raw_query, "MaxItems") {
1567            let n: i64 = max_items.parse().map_err(|_| {
1568                invalid_argument(format!("MaxItems must be an integer, got '{max_items}'"))
1569            })?;
1570            if n > 100 {
1571                return Err(invalid_argument(format!(
1572                    "MaxItems {n} exceeds maximum 100"
1573                )));
1574            }
1575        }
1576        // We never produce conflicts because every alias is owned by one
1577        // distribution at most. Return an empty list with the proper shape.
1578        let body = format!(
1579            "{XML_DECL}<ConflictingAliasesList xmlns=\"{NS}\"><Quantity>0</Quantity></ConflictingAliasesList>",
1580            NS = crate::NAMESPACE,
1581            XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
1582        );
1583        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1584    }
1585
1586    fn associate_web_acl(
1587        &self,
1588        req: &AwsRequest,
1589        route: &Route,
1590    ) -> Result<AwsResponse, AwsServiceError> {
1591        let id = route
1592            .id
1593            .as_deref()
1594            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1595        let parsed: AssociateAliasRequest = xml_io::from_xml_root(&req.body)
1596            .map_err(|e| invalid_argument(format!("invalid request XML: {e}")))?;
1597        let mut state = self.state.write();
1598        let account = state
1599            .accounts
1600            .get_mut(DEFAULT_ACCOUNT)
1601            .ok_or_else(|| no_such_distribution(id))?;
1602        let dist = account
1603            .distributions
1604            .get_mut(id)
1605            .ok_or_else(|| no_such_distribution(id))?;
1606        dist.config.web_acl_id = Some(parsed.web_acl_arn.clone());
1607        dist.etag = generate_etag();
1608        dist.last_modified_time = Utc::now();
1609        let body = format!(
1610            "{XML_DECL}<AssociateDistributionWebACLResult xmlns=\"{NS}\"><Id>{}</Id><WebACLArn>{}</WebACLArn></AssociateDistributionWebACLResult>",
1611            esc(id), esc(&parsed.web_acl_arn),
1612            NS = crate::NAMESPACE,
1613            XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
1614        );
1615        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1616    }
1617
1618    fn disassociate_web_acl(
1619        &self,
1620        _req: &AwsRequest,
1621        route: &Route,
1622    ) -> Result<AwsResponse, AwsServiceError> {
1623        let id = route
1624            .id
1625            .as_deref()
1626            .ok_or_else(|| invalid_argument("missing distribution id"))?;
1627        // DisassociateDistributionWebACL's Smithy model declares EntityNotFound
1628        // (not NoSuchDistribution) for unknown distribution IDs.
1629        let entity_not_found = || {
1630            aws_error(
1631                StatusCode::NOT_FOUND,
1632                "EntityNotFound",
1633                format!("The specified distribution does not exist: {id}"),
1634            )
1635        };
1636        let mut state = self.state.write();
1637        let account = state
1638            .accounts
1639            .get_mut(DEFAULT_ACCOUNT)
1640            .ok_or_else(entity_not_found)?;
1641        let dist = account
1642            .distributions
1643            .get_mut(id)
1644            .ok_or_else(entity_not_found)?;
1645        dist.config.web_acl_id = None;
1646        dist.etag = generate_etag();
1647        dist.last_modified_time = Utc::now();
1648        let body = format!(
1649            "{XML_DECL}<DisassociateDistributionWebACLResult xmlns=\"{NS}\"><Id>{}</Id></DisassociateDistributionWebACLResult>",
1650            esc(id),
1651            NS = crate::NAMESPACE,
1652            XML_DECL = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
1653        );
1654        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
1655    }
1656}
1657
1658#[derive(serde::Deserialize, Default, Debug)]
1659#[serde(rename_all = "PascalCase")]
1660struct AssociateAliasRequest {
1661    #[serde(rename = "WebACLArn", default)]
1662    web_acl_arn: String,
1663}
1664
1665// ─── XML body builders ────────────────────────────────────────────────
1666
1667/// XML-escape a user-provided string before injecting it into hand-rolled
1668/// XML response bodies. The 5 standard XML metacharacters are escaped;
1669/// everything else passes through unchanged. Keep this in sync with
1670/// `quick_xml`'s entity table — using their own primitive would mean a
1671/// `Writer` per call and we serialize directly into a `String`.
1672pub(crate) fn esc(s: &str) -> String {
1673    let mut out = String::with_capacity(s.len());
1674    for c in s.chars() {
1675        match c {
1676            '&' => out.push_str("&amp;"),
1677            '<' => out.push_str("&lt;"),
1678            '>' => out.push_str("&gt;"),
1679            '"' => out.push_str("&quot;"),
1680            '\'' => out.push_str("&apos;"),
1681            _ => out.push(c),
1682        }
1683    }
1684    out
1685}
1686
1687pub(crate) fn build_distribution_xml(dist: &StoredDistribution) -> String {
1688    let mut out = String::with_capacity(2048);
1689    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1690    out.push_str(&format!(
1691        "<Distribution xmlns=\"{ns}\">",
1692        ns = crate::NAMESPACE
1693    ));
1694    out.push_str(&format!("<Id>{}</Id>", esc(&dist.id)));
1695    out.push_str(&format!("<ARN>{}</ARN>", esc(&dist.arn)));
1696    out.push_str(&format!("<Status>{}</Status>", esc(&dist.status)));
1697    out.push_str(&format!(
1698        "<LastModifiedTime>{}</LastModifiedTime>",
1699        rfc3339(&dist.last_modified_time)
1700    ));
1701    out.push_str(&format!(
1702        "<InProgressInvalidationBatches>{}</InProgressInvalidationBatches>",
1703        dist.in_progress_invalidation_batches
1704    ));
1705    out.push_str(&format!(
1706        "<DomainName>{}</DomainName>",
1707        esc(&dist.domain_name)
1708    ));
1709    out.push_str(&build_active_trusted_signers_xml(dist));
1710    out.push_str(&build_active_trusted_key_groups_xml(dist));
1711    let inner = quick_xml::se::to_string_with_root("DistributionConfig", &dist.config)
1712        .unwrap_or_else(|_| String::new());
1713    out.push_str(&inner);
1714    out.push_str("</Distribution>");
1715    out
1716}
1717
1718/// Derive the top-level `ActiveTrustedSigners` block from the trusted
1719/// signers configured across the default and additional cache behaviors.
1720/// AWS reports the union of every configured signer here (rather than the
1721/// always-empty placeholder we used to emit), mirroring the
1722/// streaming-distribution derivation. We don't mint CloudFront key pairs,
1723/// so each signer carries an empty `KeyPairIds` list.
1724fn build_active_trusted_signers_xml(dist: &StoredDistribution) -> String {
1725    let mut accounts: Vec<String> = Vec::new();
1726    let mut enabled = false;
1727    let iter = std::iter::once(&dist.config.default_cache_behavior.trusted_signers)
1728        .chain(distribution_cache_behaviors(dist).map(|b| &b.trusted_signers));
1729    for ts in iter.flatten() {
1730        if ts.enabled {
1731            enabled = true;
1732        }
1733        if let Some(items) = &ts.items {
1734            for a in &items.aws_account_number {
1735                if !accounts.contains(a) {
1736                    accounts.push(a.clone());
1737                }
1738            }
1739        }
1740    }
1741    let mut out = String::with_capacity(96);
1742    out.push_str("<ActiveTrustedSigners>");
1743    out.push_str(&format!("<Enabled>{enabled}</Enabled>"));
1744    out.push_str(&format!("<Quantity>{}</Quantity>", accounts.len()));
1745    if !accounts.is_empty() {
1746        out.push_str("<Items>");
1747        for a in &accounts {
1748            out.push_str("<Signer>");
1749            out.push_str(&format!("<AwsAccountNumber>{}</AwsAccountNumber>", esc(a)));
1750            out.push_str("<KeyPairIds><Quantity>0</Quantity></KeyPairIds>");
1751            out.push_str("</Signer>");
1752        }
1753        out.push_str("</Items>");
1754    }
1755    out.push_str("</ActiveTrustedSigners>");
1756    out
1757}
1758
1759/// Derive the top-level `ActiveTrustedKeyGroups` block from the trusted key
1760/// groups configured across the default and additional cache behaviors,
1761/// reporting the union of configured key-group ids instead of an empty set.
1762fn build_active_trusted_key_groups_xml(dist: &StoredDistribution) -> String {
1763    let mut key_groups: Vec<String> = Vec::new();
1764    let mut enabled = false;
1765    let iter = std::iter::once(&dist.config.default_cache_behavior.trusted_key_groups)
1766        .chain(distribution_cache_behaviors(dist).map(|b| &b.trusted_key_groups));
1767    for tkg in iter.flatten() {
1768        if tkg.enabled {
1769            enabled = true;
1770        }
1771        if let Some(items) = &tkg.items {
1772            for g in &items.key_group {
1773                if !key_groups.contains(g) {
1774                    key_groups.push(g.clone());
1775                }
1776            }
1777        }
1778    }
1779    let mut out = String::with_capacity(96);
1780    out.push_str("<ActiveTrustedKeyGroups>");
1781    out.push_str(&format!("<Enabled>{enabled}</Enabled>"));
1782    out.push_str(&format!("<Quantity>{}</Quantity>", key_groups.len()));
1783    if !key_groups.is_empty() {
1784        out.push_str("<Items>");
1785        for g in &key_groups {
1786            out.push_str("<KeyGroup>");
1787            out.push_str(&format!("<KeyGroupId>{}</KeyGroupId>", esc(g)));
1788            out.push_str("<KeyPairIds><Quantity>0</Quantity></KeyPairIds>");
1789            out.push_str("</KeyGroup>");
1790        }
1791        out.push_str("</Items>");
1792    }
1793    out.push_str("</ActiveTrustedKeyGroups>");
1794    out
1795}
1796
1797fn build_distribution_list_xml(
1798    dists: &[StoredDistribution],
1799    root: &str,
1800    marker: &str,
1801    max_items: usize,
1802    next_marker: Option<&str>,
1803) -> String {
1804    let mut out = String::with_capacity(2048);
1805    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1806    out.push_str(&format!("<{root} xmlns=\"{ns}\">", ns = crate::NAMESPACE));
1807    out.push_str(&format!("<Marker>{}</Marker>", esc(marker)));
1808    if let Some(nm) = next_marker {
1809        out.push_str(&format!("<NextMarker>{}</NextMarker>", esc(nm)));
1810    }
1811    out.push_str(&format!("<MaxItems>{max_items}</MaxItems>"));
1812    out.push_str(&format!(
1813        "<IsTruncated>{}</IsTruncated>",
1814        next_marker.is_some()
1815    ));
1816    out.push_str(&format!("<Quantity>{}</Quantity>", dists.len()));
1817    if dists.is_empty() {
1818        out.push_str(&format!("</{root}>"));
1819        return out;
1820    }
1821    out.push_str("<Items>");
1822    for d in dists {
1823        out.push_str("<DistributionSummary>");
1824        out.push_str(&format!("<Id>{}</Id>", esc(&d.id)));
1825        out.push_str(&format!("<ARN>{}</ARN>", esc(&d.arn)));
1826        out.push_str(&format!("<Status>{}</Status>", esc(&d.status)));
1827        out.push_str(&format!(
1828            "<LastModifiedTime>{}</LastModifiedTime>",
1829            rfc3339(&d.last_modified_time)
1830        ));
1831        out.push_str(&format!("<DomainName>{}</DomainName>", esc(&d.domain_name)));
1832        let aliases = d.config.aliases.clone().unwrap_or_default();
1833        out.push_str(&render_inline("Aliases", &aliases));
1834        let origins = d.config.origins.clone();
1835        out.push_str(&render_inline("Origins", &origins));
1836        let dcb = d.config.default_cache_behavior.clone();
1837        out.push_str(&render_inline("DefaultCacheBehavior", &dcb));
1838        let cb = d.config.cache_behaviors.clone().unwrap_or_default();
1839        out.push_str(&render_inline("CacheBehaviors", &cb));
1840        let cer = d.config.custom_error_responses.clone().unwrap_or_default();
1841        out.push_str(&render_inline("CustomErrorResponses", &cer));
1842        out.push_str(&format!("<Comment>{}</Comment>", esc(&d.config.comment)));
1843        out.push_str(&format!(
1844            "<PriceClass>{}</PriceClass>",
1845            esc(&d
1846                .config
1847                .price_class
1848                .clone()
1849                .unwrap_or_else(|| "PriceClass_All".to_string()))
1850        ));
1851        out.push_str(&format!("<Enabled>{}</Enabled>", d.config.enabled));
1852        out.push_str(&render_inline(
1853            "ViewerCertificate",
1854            &d.config.viewer_certificate.clone().unwrap_or_default(),
1855        ));
1856        out.push_str(&render_inline(
1857            "Restrictions",
1858            &d.config.restrictions.clone().unwrap_or_default(),
1859        ));
1860        out.push_str(&format!(
1861            "<WebACLId>{}</WebACLId>",
1862            esc(&d.config.web_acl_id.clone().unwrap_or_default())
1863        ));
1864        out.push_str(&format!(
1865            "<HttpVersion>{}</HttpVersion>",
1866            esc(&d
1867                .config
1868                .http_version
1869                .clone()
1870                .unwrap_or_else(|| "http2".to_string()))
1871        ));
1872        out.push_str(&format!(
1873            "<IsIPV6Enabled>{}</IsIPV6Enabled>",
1874            d.config.is_ipv6_enabled.unwrap_or(true)
1875        ));
1876        out.push_str(&format!(
1877            "<Staging>{}</Staging>",
1878            d.config.staging.unwrap_or(false)
1879        ));
1880        out.push_str("</DistributionSummary>");
1881    }
1882    out.push_str("</Items>");
1883    out.push_str(&format!("</{root}>"));
1884    out
1885}
1886
1887fn build_empty_distribution_id_list(root: &str) -> String {
1888    let mut out = String::with_capacity(256);
1889    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1890    out.push_str(&format!("<{root} xmlns=\"{ns}\">", ns = crate::NAMESPACE));
1891    out.push_str("<Marker></Marker>");
1892    out.push_str("<MaxItems>100</MaxItems>");
1893    out.push_str("<IsTruncated>false</IsTruncated>");
1894    out.push_str("<Quantity>0</Quantity>");
1895    out.push_str(&format!("</{root}>"));
1896    out
1897}
1898
1899/// Render a `DistributionIdList` response body listing the given ids, echoing
1900/// the Marker/MaxItems cursor and NextMarker/IsTruncated pagination metadata.
1901fn build_distribution_id_list_xml(
1902    ids: &[&str],
1903    marker: &str,
1904    max_items: usize,
1905    next_marker: Option<&str>,
1906) -> String {
1907    let mut out = String::with_capacity(256);
1908    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
1909    out.push_str(&format!(
1910        "<DistributionIdList xmlns=\"{ns}\">",
1911        ns = crate::NAMESPACE
1912    ));
1913    out.push_str(&format!("<Marker>{}</Marker>", esc(marker)));
1914    if let Some(nm) = next_marker {
1915        out.push_str(&format!("<NextMarker>{}</NextMarker>", esc(nm)));
1916    }
1917    out.push_str(&format!("<MaxItems>{max_items}</MaxItems>"));
1918    out.push_str(&format!(
1919        "<IsTruncated>{}</IsTruncated>",
1920        next_marker.is_some()
1921    ));
1922    out.push_str(&format!("<Quantity>{}</Quantity>", ids.len()));
1923    if !ids.is_empty() {
1924        out.push_str("<Items>");
1925        for id in ids {
1926            out.push_str(&format!("<DistributionId>{}</DistributionId>", esc(id)));
1927        }
1928        out.push_str("</Items>");
1929    }
1930    out.push_str("</DistributionIdList>");
1931    out
1932}
1933
1934/// Apply the CloudFront Marker/MaxItems pagination scheme (the same one the
1935/// plain `ListDistributions` handler uses) to an already-filtered, sorted set
1936/// of distributions. Returns the echoed marker, the effective MaxItems, the
1937/// page slice, and the NextMarker cursor when the result is truncated.
1938fn paginate_distributions(
1939    req: &AwsRequest,
1940    items: Vec<StoredDistribution>,
1941) -> (String, usize, Vec<StoredDistribution>, Option<String>) {
1942    let max_items = req
1943        .query_params
1944        .get("MaxItems")
1945        .or_else(|| req.query_params.get("maxitems"))
1946        .and_then(|v| v.parse::<usize>().ok())
1947        .filter(|n| *n > 0)
1948        .unwrap_or(100);
1949    let marker = req
1950        .query_params
1951        .get("Marker")
1952        .or_else(|| req.query_params.get("marker"))
1953        .cloned()
1954        .unwrap_or_default();
1955    // CloudFront markers are EXCLUSIVE: a supplied Marker means "start after
1956    // this id". If the marker id isn't found, the page is empty (start past
1957    // the end), matching AWS.
1958    let start_idx = if marker.is_empty() {
1959        0
1960    } else {
1961        match items.iter().position(|d| d.id == marker) {
1962            Some(pos) => pos + 1,
1963            None => items.len(),
1964        }
1965    };
1966    let page: Vec<StoredDistribution> = items
1967        .iter()
1968        .skip(start_idx)
1969        .take(max_items)
1970        .cloned()
1971        .collect();
1972    // Truncated when unread items remain after this page. NextMarker is the id
1973    // of the LAST item on the current page (where listing left off), so the
1974    // caller passes it back as the next Marker to resume strictly after it.
1975    let has_more = start_idx + page.len() < items.len();
1976    let next_marker = if has_more {
1977        page.last().map(|d| d.id.clone())
1978    } else {
1979        None
1980    };
1981    (marker, max_items, page, next_marker)
1982}
1983
1984/// Iterate the default cache behavior followed by every additional cache
1985/// behavior of a distribution.
1986fn distribution_cache_behaviors(
1987    d: &StoredDistribution,
1988) -> impl Iterator<Item = &crate::model::CacheBehavior> {
1989    d.config
1990        .cache_behaviors
1991        .as_ref()
1992        .and_then(|cb| cb.items.as_ref())
1993        .into_iter()
1994        .flat_map(|items| items.cache_behavior.iter())
1995}
1996
1997fn distribution_uses_cache_policy(d: &StoredDistribution, id: &str) -> bool {
1998    d.config.default_cache_behavior.cache_policy_id.as_deref() == Some(id)
1999        || distribution_cache_behaviors(d).any(|b| b.cache_policy_id.as_deref() == Some(id))
2000}
2001
2002fn distribution_uses_origin_request_policy(d: &StoredDistribution, id: &str) -> bool {
2003    d.config
2004        .default_cache_behavior
2005        .origin_request_policy_id
2006        .as_deref()
2007        == Some(id)
2008        || distribution_cache_behaviors(d)
2009            .any(|b| b.origin_request_policy_id.as_deref() == Some(id))
2010}
2011
2012fn distribution_uses_response_headers_policy(d: &StoredDistribution, id: &str) -> bool {
2013    d.config
2014        .default_cache_behavior
2015        .response_headers_policy_id
2016        .as_deref()
2017        == Some(id)
2018        || distribution_cache_behaviors(d)
2019            .any(|b| b.response_headers_policy_id.as_deref() == Some(id))
2020}
2021
2022fn trusted_key_groups_contains(tkg: Option<&crate::model::TrustedKeyGroups>, id: &str) -> bool {
2023    tkg.and_then(|g| g.items.as_ref())
2024        .map(|items| items.key_group.iter().any(|k| k == id))
2025        .unwrap_or(false)
2026}
2027
2028fn distribution_uses_key_group(d: &StoredDistribution, id: &str) -> bool {
2029    trusted_key_groups_contains(
2030        d.config.default_cache_behavior.trusted_key_groups.as_ref(),
2031        id,
2032    ) || distribution_cache_behaviors(d)
2033        .any(|b| trusted_key_groups_contains(b.trusted_key_groups.as_ref(), id))
2034}
2035
2036fn distribution_uses_vpc_origin(d: &StoredDistribution, id: &str) -> bool {
2037    d.config
2038        .origins
2039        .items
2040        .as_ref()
2041        .map(|items| {
2042            items.origin.iter().any(|o| {
2043                o.vpc_origin_config
2044                    .as_ref()
2045                    .map(|v| v.vpc_origin_id == id)
2046                    .unwrap_or(false)
2047            })
2048        })
2049        .unwrap_or(false)
2050}
2051
2052fn build_invalidation_xml(inv: &StoredInvalidation) -> String {
2053    let mut out = String::with_capacity(512);
2054    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
2055    out.push_str(&format!(
2056        "<Invalidation xmlns=\"{ns}\">",
2057        ns = crate::NAMESPACE
2058    ));
2059    out.push_str(&format!("<Id>{}</Id>", esc(&inv.id)));
2060    out.push_str(&format!("<Status>{}</Status>", esc(&inv.status)));
2061    out.push_str(&format!(
2062        "<CreateTime>{}</CreateTime>",
2063        rfc3339(&inv.create_time)
2064    ));
2065    out.push_str(&render_inline("InvalidationBatch", &inv.batch));
2066    out.push_str("</Invalidation>");
2067    out
2068}
2069
2070fn build_invalidation_list_xml(items: &[&StoredInvalidation]) -> String {
2071    let mut out = String::with_capacity(1024);
2072    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
2073    out.push_str(&format!(
2074        "<InvalidationList xmlns=\"{ns}\">",
2075        ns = crate::NAMESPACE
2076    ));
2077    out.push_str("<Marker></Marker>");
2078    out.push_str("<MaxItems>100</MaxItems>");
2079    out.push_str("<IsTruncated>false</IsTruncated>");
2080    out.push_str(&format!("<Quantity>{}</Quantity>", items.len()));
2081    if !items.is_empty() {
2082        out.push_str("<Items>");
2083        for inv in items {
2084            out.push_str("<InvalidationSummary>");
2085            out.push_str(&format!("<Id>{}</Id>", esc(&inv.id)));
2086            out.push_str(&format!(
2087                "<CreateTime>{}</CreateTime>",
2088                rfc3339(&inv.create_time)
2089            ));
2090            out.push_str(&format!("<Status>{}</Status>", esc(&inv.status)));
2091            out.push_str("</InvalidationSummary>");
2092        }
2093        out.push_str("</Items>");
2094    }
2095    out.push_str("</InvalidationList>");
2096    out
2097}
2098
2099fn build_tags_xml(tags: &[Tag]) -> String {
2100    let mut out = String::with_capacity(256);
2101    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
2102    out.push_str(&format!("<Tags xmlns=\"{ns}\">", ns = crate::NAMESPACE));
2103    out.push_str("<Items>");
2104    for t in tags {
2105        out.push_str("<Tag>");
2106        out.push_str(&format!("<Key>{}</Key>", esc(&t.key)));
2107        if let Some(v) = &t.value {
2108            out.push_str(&format!("<Value>{}</Value>", esc(v)));
2109        }
2110        out.push_str("</Tag>");
2111    }
2112    out.push_str("</Items>");
2113    out.push_str("</Tags>");
2114    out
2115}
2116
2117fn render_inline<T: serde::Serialize>(root: &str, value: &T) -> String {
2118    quick_xml::se::to_string_with_root(root, value).unwrap_or_default()
2119}
2120
2121// ─── Helpers ──────────────────────────────────────────────────────────
2122
2123fn validate_caller_reference(s: &str) -> Result<(), AwsServiceError> {
2124    if s.is_empty() {
2125        return Err(invalid_argument("CallerReference is required"));
2126    }
2127    Ok(())
2128}
2129
2130fn validate_origins(config: &DistributionConfig) -> Result<(), AwsServiceError> {
2131    if config.origins.quantity < 1 {
2132        return Err(invalid_argument(
2133            "DistributionConfig.Origins must contain at least one origin",
2134        ));
2135    }
2136    Ok(())
2137}
2138
2139/// Compare two `DistributionConfig`s by serializing them to canonical XML
2140/// and comparing bytes. Used by `UpdateDistribution` to detect no-op writes
2141/// so the ETag stays stable when the caller PUTs the same config back.
2142/// Falls back to "not equal" if either serialization fails so we still
2143/// honor the request rather than silently swallow a write.
2144fn configs_equal(lhs: &DistributionConfig, rhs: &DistributionConfig) -> bool {
2145    let Ok(a) = xml_io::to_xml_root("DistributionConfig", lhs) else {
2146        return false;
2147    };
2148    let Ok(b) = xml_io::to_xml_root("DistributionConfig", rhs) else {
2149        return false;
2150    };
2151    a == b
2152}
2153
2154fn account_id(_req: &AwsRequest) -> &'static str {
2155    // Multi-account is wired through AwsRequest.account_id elsewhere; the
2156    // CloudFront control plane only uses the resolved id for the ARN
2157    // suffix. Until that field stabilizes for REST-XML we use the default
2158    // account ID consistently with the rest of the registered services.
2159    DEFAULT_ACCOUNT
2160}
2161
2162fn generate_distribution_id() -> String {
2163    // CloudFront IDs are 14-char base32-ish uppercase strings starting with E.
2164    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
2165    format!("E{}", &raw[..13])
2166}
2167
2168fn generate_invalidation_id() -> String {
2169    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
2170    format!("I{}", &raw[..13])
2171}
2172
2173pub(crate) fn generate_etag() -> String {
2174    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
2175    format!("E{}", &raw[..13])
2176}
2177
2178/// Generate an AWS-shaped CloudFront resource ID with the given prefix.
2179/// Used by Batch 2 policy resources (cache, origin request, response
2180/// headers, continuous deployment, OAC) so each gets a recognizable
2181/// alphabetic prefix in addition to the random suffix.
2182pub(crate) fn generate_id_with_prefix(prefix: &str) -> String {
2183    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
2184    let suffix_len = 14usize.saturating_sub(prefix.len()).min(raw.len());
2185    format!("{prefix}{}", &raw[..suffix_len])
2186}
2187
2188fn rfc3339(t: &chrono::DateTime<chrono::Utc>) -> String {
2189    t.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
2190}
2191
2192pub(crate) fn invalid_argument(msg: impl Into<String>) -> AwsServiceError {
2193    aws_error(StatusCode::BAD_REQUEST, "InvalidArgument", msg)
2194}
2195
2196fn no_such_distribution(id: &str) -> AwsServiceError {
2197    aws_error(
2198        StatusCode::NOT_FOUND,
2199        "NoSuchDistribution",
2200        format!("The specified distribution does not exist: {id}"),
2201    )
2202}
2203
2204fn no_such_invalidation(id: &str) -> AwsServiceError {
2205    aws_error(
2206        StatusCode::NOT_FOUND,
2207        "NoSuchInvalidation",
2208        format!("The specified invalidation does not exist: {id}"),
2209    )
2210}
2211
2212fn internal_error(msg: impl Into<String>) -> AwsServiceError {
2213    aws_error(StatusCode::INTERNAL_SERVER_ERROR, "InternalError", msg)
2214}
2215
2216pub(crate) fn aws_error(
2217    status: StatusCode,
2218    code: impl Into<String>,
2219    msg: impl Into<String>,
2220) -> AwsServiceError {
2221    AwsServiceError::aws_error(status, code.into(), msg)
2222}
2223
2224fn set_header(headers: &mut HeaderMap, name: HeaderName, value: &str) {
2225    if let Ok(v) = HeaderValue::from_str(value) {
2226        headers.insert(name, v);
2227    }
2228}
2229
2230pub(crate) fn xml_response(status: StatusCode, body: String, headers: HeaderMap) -> AwsResponse {
2231    AwsResponse {
2232        status,
2233        content_type: "text/xml".to_string(),
2234        body: ResponseBody::Bytes(Bytes::from(body)),
2235        headers,
2236    }
2237}
2238
2239fn empty_response(status: StatusCode) -> AwsResponse {
2240    AwsResponse {
2241        status,
2242        content_type: "text/xml".to_string(),
2243        body: ResponseBody::Bytes(Bytes::new()),
2244        headers: HeaderMap::new(),
2245    }
2246}
2247
2248fn percent_decode(input: &str) -> String {
2249    let mut out = String::with_capacity(input.len());
2250    let bytes = input.as_bytes();
2251    let mut i = 0;
2252    while i < bytes.len() {
2253        let b = bytes[i];
2254        if b == b'%' && i + 2 < bytes.len() {
2255            if let (Some(a), Some(c)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
2256                out.push(((a << 4) | c) as char);
2257                i += 3;
2258                continue;
2259            }
2260        }
2261        if b == b'+' {
2262            out.push(' ');
2263        } else {
2264            out.push(b as char);
2265        }
2266        i += 1;
2267    }
2268    out
2269}
2270
2271fn hex_digit(b: u8) -> Option<u8> {
2272    match b {
2273        b'0'..=b'9' => Some(b - b'0'),
2274        b'a'..=b'f' => Some(b - b'a' + 10),
2275        b'A'..=b'F' => Some(b - b'A' + 10),
2276        _ => None,
2277    }
2278}
2279
2280/// True when a URL-decoded label segment looks like an unsubstituted Smithy
2281/// URI placeholder (e.g. `{Identifier}` or its percent-encoded form
2282/// `%7BIdentifier%7D`). Conformance probes that omit a required `@httpLabel`
2283/// input leave the literal placeholder in the URL; handlers that can
2284/// short-circuit on it return the declared validation error instead of a
2285/// fabricated 200.
2286pub(crate) fn is_placeholder_label(value: &str) -> bool {
2287    if value.is_empty() {
2288        return true;
2289    }
2290    let lower = value.to_ascii_lowercase();
2291    value.starts_with('{') || lower.starts_with("%7b")
2292}
2293
2294/// Best-effort field extractor for request bodies the probe sends as JSON
2295/// even when the service is REST-XML. Walks the body trying JSON first,
2296/// then a naive XML element scan. Returns the value for the first occurrence
2297/// of `key`. Used by handlers that need to validate optional/required body
2298/// members up-front (enum bounds, length, presence) without committing to a
2299/// full strongly-typed parse — the actual handler still uses the typed XML
2300/// parser for the structured fields it consumes.
2301pub(crate) fn extract_body_field(body: &[u8], key: &str) -> Option<String> {
2302    if let Ok(s) = std::str::from_utf8(body) {
2303        let trimmed = s.trim_start();
2304        if trimmed.starts_with('{') {
2305            if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
2306                if let Some(field) = v.get(key) {
2307                    return match field {
2308                        serde_json::Value::String(s) => Some(s.clone()),
2309                        serde_json::Value::Number(n) => Some(n.to_string()),
2310                        serde_json::Value::Bool(b) => Some(b.to_string()),
2311                        _ => None,
2312                    };
2313                }
2314                return None;
2315            }
2316        }
2317        // Fall back to a naive XML extraction for genuine XML bodies.
2318        let open = format!("<{key}>");
2319        let close = format!("</{key}>");
2320        if let Some(start) = s.find(&open) {
2321            let after = start + open.len();
2322            if let Some(end_rel) = s[after..].find(&close) {
2323                return Some(s[after..after + end_rel].to_string());
2324            }
2325        }
2326    }
2327    None
2328}
2329
2330fn parse_query_value(query: &str, key: &str) -> Option<String> {
2331    let prefix = format!("{key}=");
2332    for pair in query.split('&').filter(|p| !p.is_empty()) {
2333        if let Some(rest) = pair.strip_prefix(&prefix) {
2334            return Some(percent_decode(rest));
2335        }
2336    }
2337    None
2338}
2339
2340#[cfg(test)]
2341mod tests {
2342    use super::*;
2343
2344    #[test]
2345    fn distribution_list_xml_renders_pagination_fields() {
2346        // Truncated page: echoes the request marker, the requested MaxItems,
2347        // IsTruncated=true, and the NextMarker cursor.
2348        let xml =
2349            build_distribution_list_xml(&[], "DistributionList", "EDFDVBD6", 2, Some("E2NEXT"));
2350        assert!(xml.contains("<Marker>EDFDVBD6</Marker>"));
2351        assert!(xml.contains("<MaxItems>2</MaxItems>"));
2352        assert!(xml.contains("<IsTruncated>true</IsTruncated>"));
2353        assert!(xml.contains("<NextMarker>E2NEXT</NextMarker>"));
2354        // Final page: no NextMarker, IsTruncated=false.
2355        let xml = build_distribution_list_xml(&[], "DistributionList", "", 100, None);
2356        assert!(xml.contains("<IsTruncated>false</IsTruncated>"));
2357        assert!(!xml.contains("<NextMarker>"));
2358    }
2359
2360    #[test]
2361    fn placeholder_label_detects_braces_and_percent_encoding() {
2362        assert!(is_placeholder_label(""));
2363        assert!(is_placeholder_label("{Identifier}"));
2364        assert!(is_placeholder_label("%7BIdentifier%7D"));
2365        assert!(is_placeholder_label("%7bidentifier%7d"));
2366        assert!(!is_placeholder_label("E1234567890ABC"));
2367        assert!(!is_placeholder_label(
2368            "arn:aws:cloudfront::000:distribution/E1"
2369        ));
2370    }
2371
2372    #[test]
2373    fn extract_body_field_handles_json_and_xml() {
2374        let json = br#"{"Stage":"BROKEN","Marker":"x"}"#;
2375        assert_eq!(
2376            extract_body_field(json, "Stage"),
2377            Some("BROKEN".to_string())
2378        );
2379        assert_eq!(extract_body_field(json, "MaxItems"), None);
2380
2381        let xml = br#"<?xml version="1.0"?><Body><Domain>example.com</Domain></Body>"#;
2382        assert_eq!(
2383            extract_body_field(xml, "Domain"),
2384            Some("example.com".to_string())
2385        );
2386        assert_eq!(extract_body_field(xml, "Missing"), None);
2387
2388        assert_eq!(extract_body_field(b"", "x"), None);
2389    }
2390
2391    fn stored_dist_with_config(config: crate::model::DistributionConfig) -> StoredDistribution {
2392        StoredDistribution {
2393            id: "E1TEST".to_string(),
2394            arn: "arn:aws:cloudfront::123456789012:distribution/E1TEST".to_string(),
2395            status: "Deployed".to_string(),
2396            last_modified_time: chrono::Utc::now(),
2397            domain_name: "d1.cloudfront.net".to_string(),
2398            in_progress_invalidation_batches: 0,
2399            etag: "E2ETAG".to_string(),
2400            config,
2401        }
2402    }
2403
2404    #[test]
2405    fn active_trusted_key_groups_and_signers_reflect_config() {
2406        use crate::model::{
2407            AwsAccountNumberList, TrustedKeyGroupIdList, TrustedKeyGroups, TrustedSigners,
2408        };
2409        let mut config = crate::model::DistributionConfig::default();
2410        // Trusted key group on the default cache behavior.
2411        config.default_cache_behavior.trusted_key_groups = Some(TrustedKeyGroups {
2412            enabled: true,
2413            quantity: 1,
2414            items: Some(TrustedKeyGroupIdList {
2415                key_group: vec!["kg-abc".to_string()],
2416            }),
2417        });
2418        // Trusted signer on the default cache behavior.
2419        config.default_cache_behavior.trusted_signers = Some(TrustedSigners {
2420            enabled: true,
2421            quantity: 1,
2422            items: Some(AwsAccountNumberList {
2423                aws_account_number: vec!["self".to_string()],
2424            }),
2425        });
2426        let dist = stored_dist_with_config(config);
2427        let xml = build_distribution_xml(&dist);
2428        assert!(
2429            xml.contains("<ActiveTrustedKeyGroups><Enabled>true</Enabled><Quantity>1</Quantity>")
2430        );
2431        assert!(xml.contains("<KeyGroupId>kg-abc</KeyGroupId>"));
2432        assert!(xml.contains("<ActiveTrustedSigners><Enabled>true</Enabled><Quantity>1</Quantity>"));
2433        assert!(xml.contains("<AwsAccountNumber>self</AwsAccountNumber>"));
2434    }
2435
2436    #[test]
2437    fn active_trusted_blocks_empty_when_unconfigured() {
2438        let dist = stored_dist_with_config(crate::model::DistributionConfig::default());
2439        let xml = build_distribution_xml(&dist);
2440        assert!(xml.contains(
2441            "<ActiveTrustedSigners><Enabled>false</Enabled><Quantity>0</Quantity></ActiveTrustedSigners>"
2442        ));
2443        assert!(xml.contains(
2444            "<ActiveTrustedKeyGroups><Enabled>false</Enabled><Quantity>0</Quantity></ActiveTrustedKeyGroups>"
2445        ));
2446    }
2447
2448    fn make_state() -> SharedCloudFrontState {
2449        Arc::new(RwLock::new(CloudFrontAccounts::new()))
2450    }
2451
2452    fn make_request(method: http::Method, path: &str, query: &str, body: &str) -> AwsRequest {
2453        AwsRequest {
2454            service: "cloudfront".into(),
2455            action: String::new(),
2456            region: "us-east-1".into(),
2457            account_id: DEFAULT_ACCOUNT.into(),
2458            request_id: Uuid::new_v4().to_string(),
2459            headers: HeaderMap::new(),
2460            query_params: std::collections::HashMap::new(),
2461            body_stream: parking_lot::Mutex::new(None),
2462            body: Bytes::from(body.to_string()),
2463            path_segments: path
2464                .split('/')
2465                .filter(|s| !s.is_empty())
2466                .map(String::from)
2467                .collect(),
2468            raw_path: path.into(),
2469            raw_query: query.into(),
2470            method,
2471            is_query_protocol: false,
2472            access_key_id: None,
2473            principal: None,
2474        }
2475    }
2476
2477    fn minimal_dist_config_xml(caller_ref: &str) -> String {
2478        format!(
2479            r#"<?xml version="1.0" encoding="UTF-8"?>
2480<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
2481  <CallerReference>{caller_ref}</CallerReference>
2482  <Origins>
2483    <Quantity>1</Quantity>
2484    <Items>
2485      <Origin>
2486        <Id>primary</Id>
2487        <DomainName>example.com</DomainName>
2488      </Origin>
2489    </Items>
2490  </Origins>
2491  <DefaultCacheBehavior>
2492    <TargetOriginId>primary</TargetOriginId>
2493    <ViewerProtocolPolicy>allow-all</ViewerProtocolPolicy>
2494  </DefaultCacheBehavior>
2495  <Comment></Comment>
2496  <Enabled>true</Enabled>
2497</DistributionConfig>"#
2498        )
2499    }
2500
2501    #[tokio::test]
2502    async fn create_then_get_then_delete_distribution() {
2503        let svc = CloudFrontService::new(make_state());
2504        let body = minimal_dist_config_xml("ref-1");
2505        let create = svc
2506            .handle(make_request(
2507                http::Method::POST,
2508                "/2020-05-31/distribution",
2509                "",
2510                &body,
2511            ))
2512            .await
2513            .unwrap();
2514        assert_eq!(create.status, StatusCode::CREATED);
2515        let etag = create
2516            .headers
2517            .get(ETAG)
2518            .unwrap()
2519            .to_str()
2520            .unwrap()
2521            .to_string();
2522        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
2523        let id = xml
2524            .split("<Id>")
2525            .nth(1)
2526            .unwrap()
2527            .split("</Id>")
2528            .next()
2529            .unwrap()
2530            .to_string();
2531
2532        let get = svc
2533            .handle(make_request(
2534                http::Method::GET,
2535                &format!("/2020-05-31/distribution/{id}"),
2536                "",
2537                "",
2538            ))
2539            .await
2540            .unwrap();
2541        assert_eq!(get.status, StatusCode::OK);
2542
2543        // Disable then delete (CloudFront requires Disabled before delete).
2544        let disable_body = body.replace("<Enabled>true</Enabled>", "<Enabled>false</Enabled>");
2545        let mut update_req = make_request(
2546            http::Method::PUT,
2547            &format!("/2020-05-31/distribution/{id}/config"),
2548            "",
2549            &disable_body,
2550        );
2551        update_req.headers.insert(IF_MATCH, etag.parse().unwrap());
2552        let updated = svc.handle(update_req).await.unwrap();
2553        assert_eq!(updated.status, StatusCode::OK);
2554        let new_etag = updated
2555            .headers
2556            .get(ETAG)
2557            .unwrap()
2558            .to_str()
2559            .unwrap()
2560            .to_string();
2561
2562        let mut del_req = make_request(
2563            http::Method::DELETE,
2564            &format!("/2020-05-31/distribution/{id}"),
2565            "",
2566            "",
2567        );
2568        del_req.headers.insert(IF_MATCH, new_etag.parse().unwrap());
2569        let del = svc.handle(del_req).await.unwrap();
2570        assert_eq!(del.status, StatusCode::NO_CONTENT);
2571    }
2572
2573    async fn create_distribution_returning_id(svc: &CloudFrontService, caller_ref: &str) -> String {
2574        let body = minimal_dist_config_xml(caller_ref);
2575        let create = svc
2576            .handle(make_request(
2577                http::Method::POST,
2578                "/2020-05-31/distribution",
2579                "",
2580                &body,
2581            ))
2582            .await
2583            .unwrap();
2584        let xml = std::str::from_utf8(create.body.expect_bytes())
2585            .unwrap()
2586            .to_string();
2587        xml.split("<Id>")
2588            .nth(1)
2589            .unwrap()
2590            .split("</Id>")
2591            .next()
2592            .unwrap()
2593            .to_string()
2594    }
2595
2596    fn distribution_status(svc: &CloudFrontService, id: &str) -> String {
2597        let state = svc.state.read();
2598        state
2599            .accounts
2600            .get(DEFAULT_ACCOUNT)
2601            .and_then(|a| a.distributions.get(id))
2602            .map(|d| d.status.clone())
2603            .unwrap_or_default()
2604    }
2605
2606    #[tokio::test]
2607    async fn create_distribution_starts_in_progress() {
2608        // Use a long delay so the auto-tick can't race the assertion.
2609        let svc = CloudFrontService::new(make_state())
2610            .with_propagation_delay(std::time::Duration::from_secs(60));
2611        let body = minimal_dist_config_xml("status-ref");
2612        let create = svc
2613            .handle(make_request(
2614                http::Method::POST,
2615                "/2020-05-31/distribution",
2616                "",
2617                &body,
2618            ))
2619            .await
2620            .unwrap();
2621        let xml = std::str::from_utf8(create.body.expect_bytes())
2622            .unwrap()
2623            .to_string();
2624        assert!(
2625            xml.contains("<Status>InProgress</Status>"),
2626            "expected initial status InProgress, got: {xml}"
2627        );
2628    }
2629
2630    #[tokio::test]
2631    async fn auto_transition_after_tick_marks_deployed() {
2632        let svc = CloudFrontService::new(make_state())
2633            .with_propagation_delay(std::time::Duration::from_millis(50));
2634        let id = create_distribution_returning_id(&svc, "auto-tick-ref").await;
2635        assert_eq!(distribution_status(&svc, &id), "InProgress");
2636        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2637        assert_eq!(distribution_status(&svc, &id), "Deployed");
2638    }
2639
2640    #[tokio::test]
2641    async fn set_distribution_status_via_admin_flips_synchronously() {
2642        let svc = CloudFrontService::new(make_state())
2643            .with_propagation_delay(std::time::Duration::from_secs(60));
2644        let id = create_distribution_returning_id(&svc, "admin-ref").await;
2645        assert_eq!(distribution_status(&svc, &id), "InProgress");
2646        assert!(svc.set_distribution_status(&id, "Deployed"));
2647        assert_eq!(distribution_status(&svc, &id), "Deployed");
2648        assert!(svc.set_distribution_status(&id, "InProgress"));
2649        assert_eq!(distribution_status(&svc, &id), "InProgress");
2650    }
2651
2652    #[tokio::test]
2653    async fn set_distribution_status_unknown_id_returns_false() {
2654        let svc = CloudFrontService::new(make_state());
2655        assert!(!svc.set_distribution_status("E-DOES-NOT-EXIST", "Deployed"));
2656    }
2657
2658    #[tokio::test]
2659    async fn update_distribution_resets_to_in_progress() {
2660        let svc = CloudFrontService::new(make_state())
2661            .with_propagation_delay(std::time::Duration::from_secs(60));
2662        let body = minimal_dist_config_xml("update-reset-ref");
2663        let create = svc
2664            .handle(make_request(
2665                http::Method::POST,
2666                "/2020-05-31/distribution",
2667                "",
2668                &body,
2669            ))
2670            .await
2671            .unwrap();
2672        let etag = create
2673            .headers
2674            .get(ETAG)
2675            .unwrap()
2676            .to_str()
2677            .unwrap()
2678            .to_string();
2679        let xml = std::str::from_utf8(create.body.expect_bytes())
2680            .unwrap()
2681            .to_string();
2682        let id = xml
2683            .split("<Id>")
2684            .nth(1)
2685            .unwrap()
2686            .split("</Id>")
2687            .next()
2688            .unwrap()
2689            .to_string();
2690        // Force the distribution to Deployed via the admin mutator so we can
2691        // observe the UpdateDistribution flip back to InProgress.
2692        assert!(svc.set_distribution_status(&id, "Deployed"));
2693        assert_eq!(distribution_status(&svc, &id), "Deployed");
2694
2695        let updated_body = body.replace(
2696            "<ViewerProtocolPolicy>allow-all</ViewerProtocolPolicy>",
2697            "<ViewerProtocolPolicy>https-only</ViewerProtocolPolicy>",
2698        );
2699        let mut update_req = make_request(
2700            http::Method::PUT,
2701            &format!("/2020-05-31/distribution/{id}/config"),
2702            "",
2703            &updated_body,
2704        );
2705        update_req.headers.insert(IF_MATCH, etag.parse().unwrap());
2706        let updated = svc.handle(update_req).await.unwrap();
2707        assert_eq!(updated.status, StatusCode::OK);
2708        assert_eq!(distribution_status(&svc, &id), "InProgress");
2709    }
2710
2711    #[tokio::test]
2712    async fn duplicate_caller_reference_is_rejected() {
2713        let svc = CloudFrontService::new(make_state());
2714        let body = minimal_dist_config_xml("dup-ref");
2715        svc.handle(make_request(
2716            http::Method::POST,
2717            "/2020-05-31/distribution",
2718            "",
2719            &body,
2720        ))
2721        .await
2722        .unwrap();
2723        let result = svc
2724            .handle(make_request(
2725                http::Method::POST,
2726                "/2020-05-31/distribution",
2727                "",
2728                &body,
2729            ))
2730            .await;
2731        let err = match result {
2732            Ok(_) => panic!("expected duplicate caller-reference to fail"),
2733            Err(e) => e,
2734        };
2735        assert_eq!(err.code(), "DistributionAlreadyExists");
2736        assert_eq!(err.status(), StatusCode::CONFLICT);
2737    }
2738
2739    #[tokio::test]
2740    async fn invalidation_lifecycle() {
2741        let svc = CloudFrontService::new(make_state());
2742        let body = minimal_dist_config_xml("inv-ref");
2743        let create = svc
2744            .handle(make_request(
2745                http::Method::POST,
2746                "/2020-05-31/distribution",
2747                "",
2748                &body,
2749            ))
2750            .await
2751            .unwrap();
2752        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
2753        let dist_id = xml
2754            .split("<Id>")
2755            .nth(1)
2756            .unwrap()
2757            .split("</Id>")
2758            .next()
2759            .unwrap();
2760        let inv_body = r#"<?xml version="1.0" encoding="UTF-8"?>
2761<InvalidationBatch xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
2762  <Paths><Quantity>1</Quantity><Items><Path>/*</Path></Items></Paths>
2763  <CallerReference>inv-1</CallerReference>
2764</InvalidationBatch>"#;
2765        let inv_resp = svc
2766            .handle(make_request(
2767                http::Method::POST,
2768                &format!("/2020-05-31/distribution/{dist_id}/invalidation"),
2769                "",
2770                inv_body,
2771            ))
2772            .await
2773            .unwrap();
2774        assert_eq!(inv_resp.status, StatusCode::CREATED);
2775        let inv_xml = std::str::from_utf8(inv_resp.body.expect_bytes()).unwrap();
2776        let inv_id = inv_xml
2777            .split("<Id>")
2778            .nth(1)
2779            .unwrap()
2780            .split("</Id>")
2781            .next()
2782            .unwrap();
2783        let get = svc
2784            .handle(make_request(
2785                http::Method::GET,
2786                &format!("/2020-05-31/distribution/{dist_id}/invalidation/{inv_id}"),
2787                "",
2788                "",
2789            ))
2790            .await
2791            .unwrap();
2792        assert_eq!(get.status, StatusCode::OK);
2793        let list = svc
2794            .handle(make_request(
2795                http::Method::GET,
2796                &format!("/2020-05-31/distribution/{dist_id}/invalidation"),
2797                "",
2798                "",
2799            ))
2800            .await
2801            .unwrap();
2802        let xml = std::str::from_utf8(list.body.expect_bytes()).unwrap();
2803        assert!(xml.contains("<Quantity>1</Quantity>"));
2804    }
2805
2806    #[tokio::test]
2807    async fn tags_roundtrip() {
2808        let svc = CloudFrontService::new(make_state());
2809        let body = minimal_dist_config_xml("tag-ref");
2810        let create = svc
2811            .handle(make_request(
2812                http::Method::POST,
2813                "/2020-05-31/distribution",
2814                "",
2815                &body,
2816            ))
2817            .await
2818            .unwrap();
2819        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
2820        let arn = xml
2821            .split("<ARN>")
2822            .nth(1)
2823            .unwrap()
2824            .split("</ARN>")
2825            .next()
2826            .unwrap();
2827        let tag_body = r#"<?xml version="1.0" encoding="UTF-8"?>
2828<Tags xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
2829  <Items><Tag><Key>env</Key><Value>prod</Value></Tag></Items>
2830</Tags>"#;
2831        let arn_q = format!("Operation=Tag&Resource={}", arn);
2832        let resp = svc
2833            .handle(make_request(
2834                http::Method::POST,
2835                "/2020-05-31/tagging",
2836                &arn_q,
2837                tag_body,
2838            ))
2839            .await
2840            .unwrap();
2841        assert_eq!(resp.status, StatusCode::NO_CONTENT);
2842        let list = svc
2843            .handle(make_request(
2844                http::Method::GET,
2845                "/2020-05-31/tagging",
2846                &format!("Resource={}", arn),
2847                "",
2848            ))
2849            .await
2850            .unwrap();
2851        let xml = std::str::from_utf8(list.body.expect_bytes()).unwrap();
2852        assert!(xml.contains("<Key>env</Key>"));
2853        assert!(xml.contains("<Value>prod</Value>"));
2854    }
2855
2856    #[tokio::test]
2857    async fn xml_metacharacters_in_user_input_are_escaped() {
2858        let svc = CloudFrontService::new(make_state());
2859        let body = minimal_dist_config_xml("escape-ref").replace(
2860            "<Comment></Comment>",
2861            "<Comment><![CDATA[a&b<c>d]]></Comment>",
2862        );
2863        let create = svc
2864            .handle(make_request(
2865                http::Method::POST,
2866                "/2020-05-31/distribution",
2867                "",
2868                &body,
2869            ))
2870            .await
2871            .unwrap();
2872        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
2873        let dist_id = xml
2874            .split("<Id>")
2875            .nth(1)
2876            .unwrap()
2877            .split("</Id>")
2878            .next()
2879            .unwrap();
2880        let arn = xml
2881            .split("<ARN>")
2882            .nth(1)
2883            .unwrap()
2884            .split("</ARN>")
2885            .next()
2886            .unwrap();
2887
2888        let tag_body = r#"<?xml version="1.0" encoding="UTF-8"?>
2889<Tags xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
2890  <Items><Tag><Key>env</Key><Value>a&amp;b&lt;c&gt;d</Value></Tag></Items>
2891</Tags>"#;
2892        let arn_q = format!("Operation=Tag&Resource={}", arn);
2893        svc.handle(make_request(
2894            http::Method::POST,
2895            "/2020-05-31/tagging",
2896            &arn_q,
2897            tag_body,
2898        ))
2899        .await
2900        .unwrap();
2901
2902        let list = svc
2903            .handle(make_request(
2904                http::Method::GET,
2905                "/2020-05-31/tagging",
2906                &format!("Resource={}", arn),
2907                "",
2908            ))
2909            .await
2910            .unwrap();
2911        let xml = std::str::from_utf8(list.body.expect_bytes()).unwrap();
2912        assert!(xml.contains("<Value>a&amp;b&lt;c&gt;d</Value>"));
2913        assert!(!xml.contains("<Value>a&b<c>d</Value>"));
2914
2915        // Force the distribution into the list-rendering path so the
2916        // unescaped Comment field would surface there.
2917        let list_resp = svc
2918            .handle(make_request(
2919                http::Method::GET,
2920                "/2020-05-31/distribution",
2921                "",
2922                "",
2923            ))
2924            .await
2925            .unwrap();
2926        let xml = std::str::from_utf8(list_resp.body.expect_bytes()).unwrap();
2927        // Ensure raw `<` from the user-supplied comment never lands inside
2928        // a Comment element on the wire.
2929        assert!(!xml.contains("<Comment>a&b<c>d"));
2930        assert!(xml.contains(dist_id));
2931    }
2932
2933    fn predicate_dist_config_xml(caller_ref: &str) -> String {
2934        format!(
2935            r#"<?xml version="1.0" encoding="UTF-8"?>
2936<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
2937  <CallerReference>{caller_ref}</CallerReference>
2938  <Origins>
2939    <Quantity>1</Quantity>
2940    <Items>
2941      <Origin>
2942        <Id>primary</Id>
2943        <DomainName>example.com</DomainName>
2944        <VpcOriginConfig><VpcOriginId>vo-123</VpcOriginId></VpcOriginConfig>
2945      </Origin>
2946    </Items>
2947  </Origins>
2948  <DefaultCacheBehavior>
2949    <TargetOriginId>primary</TargetOriginId>
2950    <ViewerProtocolPolicy>allow-all</ViewerProtocolPolicy>
2951    <CachePolicyId>cp-123</CachePolicyId>
2952    <OriginRequestPolicyId>orp-123</OriginRequestPolicyId>
2953    <ResponseHeadersPolicyId>rhp-123</ResponseHeadersPolicyId>
2954    <TrustedKeyGroups>
2955      <Enabled>true</Enabled>
2956      <Quantity>1</Quantity>
2957      <Items><KeyGroup>kg-123</KeyGroup></Items>
2958    </TrustedKeyGroups>
2959  </DefaultCacheBehavior>
2960  <Comment></Comment>
2961  <WebACLId>waf-abc</WebACLId>
2962  <AnycastIpListId>ail-123</AnycastIpListId>
2963  <Enabled>true</Enabled>
2964</DistributionConfig>"#
2965        )
2966    }
2967
2968    async fn list_by(svc: &CloudFrontService, path: &str) -> String {
2969        let resp = svc
2970            .handle(make_request(http::Method::GET, path, "", ""))
2971            .await
2972            .unwrap();
2973        assert_eq!(resp.status, StatusCode::OK);
2974        std::str::from_utf8(resp.body.expect_bytes())
2975            .unwrap()
2976            .to_string()
2977    }
2978
2979    #[tokio::test]
2980    async fn list_distributions_by_predicate_fields_filters_state() {
2981        let svc = CloudFrontService::new(make_state());
2982        let create = svc
2983            .handle(make_request(
2984                http::Method::POST,
2985                "/2020-05-31/distribution",
2986                "",
2987                &predicate_dist_config_xml("pred-ref"),
2988            ))
2989            .await
2990            .unwrap();
2991        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
2992        let id = xml
2993            .split("<Id>")
2994            .nth(1)
2995            .unwrap()
2996            .split("</Id>")
2997            .next()
2998            .unwrap()
2999            .to_string();
3000
3001        // DistributionIdList-shaped ops: match id must appear, mismatch empty.
3002        for (path, root) in [
3003            ("/2020-05-31/distributionsByCachePolicyId/cp-123", "cp-123"),
3004            (
3005                "/2020-05-31/distributionsByOriginRequestPolicyId/orp-123",
3006                "orp-123",
3007            ),
3008            (
3009                "/2020-05-31/distributionsByResponseHeadersPolicyId/rhp-123",
3010                "rhp-123",
3011            ),
3012            ("/2020-05-31/distributionsByKeyGroupId/kg-123", "kg-123"),
3013            ("/2020-05-31/distributionsByVpcOriginId/vo-123", "vo-123"),
3014        ] {
3015            let body = list_by(&svc, path).await;
3016            assert!(body.contains("<DistributionIdList"), "{root}: {body}");
3017            assert!(
3018                body.contains(&format!("<DistributionId>{id}</DistributionId>")),
3019                "{root} did not list distribution: {body}"
3020            );
3021            assert!(body.contains("<Quantity>1</Quantity>"), "{root}: {body}");
3022        }
3023
3024        // Mismatched id -> empty.
3025        let empty = list_by(&svc, "/2020-05-31/distributionsByCachePolicyId/other").await;
3026        assert!(empty.contains("<Quantity>0</Quantity>"), "{empty}");
3027        assert!(!empty.contains("<DistributionId>"), "{empty}");
3028
3029        // DistributionList-shaped ops: WebACLId + AnycastIpListId.
3030        let web = list_by(&svc, "/2020-05-31/distributionsByWebACLId/waf-abc").await;
3031        assert!(web.contains("<DistributionList"), "{web}");
3032        assert!(web.contains(&format!("<Id>{id}</Id>")), "{web}");
3033
3034        let anycast = list_by(&svc, "/2020-05-31/distributionsByAnycastIpListId/ail-123").await;
3035        assert!(anycast.contains("<DistributionList"), "{anycast}");
3036        assert!(anycast.contains(&format!("<Id>{id}</Id>")), "{anycast}");
3037
3038        let anycast_miss = list_by(&svc, "/2020-05-31/distributionsByAnycastIpListId/nope").await;
3039        assert!(
3040            anycast_miss.contains("<Quantity>0</Quantity>"),
3041            "{anycast_miss}"
3042        );
3043    }
3044
3045    fn dist_config_with_cache_policy(caller_ref: &str, cache_policy: &str) -> String {
3046        format!(
3047            r#"<?xml version="1.0" encoding="UTF-8"?>
3048<DistributionConfig xmlns="http://cloudfront.amazonaws.com/doc/2020-05-31/">
3049  <CallerReference>{caller_ref}</CallerReference>
3050  <Origins>
3051    <Quantity>1</Quantity>
3052    <Items><Origin><Id>primary</Id><DomainName>example.com</DomainName></Origin></Items>
3053  </Origins>
3054  <DefaultCacheBehavior>
3055    <TargetOriginId>primary</TargetOriginId>
3056    <ViewerProtocolPolicy>allow-all</ViewerProtocolPolicy>
3057    <CachePolicyId>{cache_policy}</CachePolicyId>
3058  </DefaultCacheBehavior>
3059  <Comment></Comment>
3060  <Enabled>true</Enabled>
3061</DistributionConfig>"#
3062        )
3063    }
3064
3065    async fn create_dist_returning_id(svc: &CloudFrontService, config_xml: &str) -> String {
3066        let create = svc
3067            .handle(make_request(
3068                http::Method::POST,
3069                "/2020-05-31/distribution",
3070                "",
3071                config_xml,
3072            ))
3073            .await
3074            .unwrap();
3075        let xml = std::str::from_utf8(create.body.expect_bytes()).unwrap();
3076        xml.split("<Id>")
3077            .nth(1)
3078            .unwrap()
3079            .split("</Id>")
3080            .next()
3081            .unwrap()
3082            .to_string()
3083    }
3084
3085    fn make_request_with_params(path: &str, params: &[(&str, &str)]) -> AwsRequest {
3086        let mut r = make_request(http::Method::GET, path, "", "");
3087        r.query_params = params
3088            .iter()
3089            .map(|(k, v)| (k.to_string(), v.to_string()))
3090            .collect();
3091        r
3092    }
3093
3094    #[tokio::test]
3095    async fn list_distributions_by_web_acl_id_null_lists_unassociated() {
3096        // Finding #2: WebACLId "null" lists distributions with no web ACL.
3097        let svc = CloudFrontService::new(make_state());
3098        let with_acl = create_dist_returning_id(
3099            &svc,
3100            &predicate_dist_config_xml("null-with-acl"), // has WebACLId waf-abc
3101        )
3102        .await;
3103        let without_acl =
3104            create_dist_returning_id(&svc, &dist_config_with_cache_policy("null-no-acl", "cp-x"))
3105                .await;
3106
3107        let body = list_by(&svc, "/2020-05-31/distributionsByWebACLId/null").await;
3108        assert!(body.contains("<DistributionList"), "{body}");
3109        assert!(
3110            body.contains(&format!("<Id>{without_acl}</Id>")),
3111            "unassociated distribution missing from null listing: {body}"
3112        );
3113        assert!(
3114            !body.contains(&format!("<Id>{with_acl}</Id>")),
3115            "distribution WITH a web ACL leaked into the null listing: {body}"
3116        );
3117    }
3118
3119    fn only_distribution_id(xml: &str) -> String {
3120        assert_eq!(
3121            xml.matches("<DistributionId>").count(),
3122            1,
3123            "expected exactly one DistributionId: {xml}"
3124        );
3125        xml.split("<DistributionId>")
3126            .nth(1)
3127            .unwrap()
3128            .split("</DistributionId>")
3129            .next()
3130            .unwrap()
3131            .to_string()
3132    }
3133
3134    #[tokio::test]
3135    async fn list_distributions_by_predicate_paginates() {
3136        // Finding #3: Marker/MaxItems pagination applies to the filtered set,
3137        // with EXCLUSIVE markers (NextMarker = last id on the page; the next
3138        // request resumes strictly after it).
3139        let svc = CloudFrontService::new(make_state());
3140        // Two distributions sharing the same cache policy.
3141        let d1 =
3142            create_dist_returning_id(&svc, &dist_config_with_cache_policy("pg-1", "cp-pg")).await;
3143        let d2 =
3144            create_dist_returning_id(&svc, &dist_config_with_cache_policy("pg-2", "cp-pg")).await;
3145
3146        // First page: MaxItems=1 -> one id, truncated, NextMarker == that id.
3147        let page1 = svc
3148            .handle(make_request_with_params(
3149                "/2020-05-31/distributionsByCachePolicyId/cp-pg",
3150                &[("MaxItems", "1")],
3151            ))
3152            .await
3153            .unwrap();
3154        let xml1 = std::str::from_utf8(page1.body.expect_bytes()).unwrap();
3155        assert!(xml1.contains("<MaxItems>1</MaxItems>"), "{xml1}");
3156        assert!(xml1.contains("<IsTruncated>true</IsTruncated>"), "{xml1}");
3157        let first_id = only_distribution_id(xml1);
3158        let next = xml1
3159            .split("<NextMarker>")
3160            .nth(1)
3161            .expect("NextMarker present")
3162            .split("</NextMarker>")
3163            .next()
3164            .unwrap()
3165            .to_string();
3166        // Exclusive marker: NextMarker is the LAST id on the current page.
3167        assert_eq!(next, first_id, "NextMarker must be the last id on the page");
3168
3169        // Second page via the cursor: resumes AFTER the marker -> the other id,
3170        // never the marker item again, and not truncated.
3171        let page2 = svc
3172            .handle(make_request_with_params(
3173                "/2020-05-31/distributionsByCachePolicyId/cp-pg",
3174                &[("MaxItems", "1"), ("Marker", &next)],
3175            ))
3176            .await
3177            .unwrap();
3178        let xml2 = std::str::from_utf8(page2.body.expect_bytes()).unwrap();
3179        assert!(xml2.contains(&format!("<Marker>{next}</Marker>")), "{xml2}");
3180        assert!(xml2.contains("<IsTruncated>false</IsTruncated>"), "{xml2}");
3181        let second_id = only_distribution_id(xml2);
3182        assert_ne!(
3183            second_id, first_id,
3184            "exclusive marker must not return the marker item again"
3185        );
3186        // The two pages together cover both distributions exactly once.
3187        let mut seen = [first_id, second_id];
3188        seen.sort();
3189        let mut expected = [d1, d2];
3190        expected.sort();
3191        assert_eq!(seen, expected, "pages did not cover both distributions");
3192    }
3193}