Skip to main content

fakecloud_config/
service.rs

1//! AWS Config (`config`) awsJson1_1 service — configuration recorder, config
2//! items, rules with real evaluation, compliance, conformance packs,
3//! aggregators, remediation, retention, stored queries and resource
4//! evaluations.
5
6use std::collections::HashMap;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use http::StatusCode;
13use parking_lot::Mutex;
14use serde_json::{json, Value};
15use tokio::sync::Mutex as AsyncMutex;
16use uuid::Uuid;
17
18use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
19use fakecloud_lambda::runtime::ContainerRuntime;
20use fakecloud_lambda::SharedLambdaState;
21use fakecloud_persistence::SnapshotStore;
22
23use crate::persistence::save_config_snapshot;
24use crate::state::*;
25use crate::validate::{self, CrossServiceStates};
26
27pub const SUPPORTED_ACTIONS: &[&str] = &[
28    "AssociateResourceTypes",
29    "BatchGetAggregateResourceConfig",
30    "BatchGetResourceConfig",
31    "DeleteAggregationAuthorization",
32    "DeleteConfigRule",
33    "DeleteConfigurationAggregator",
34    "DeleteConfigurationRecorder",
35    "DeleteConformancePack",
36    "DeleteDeliveryChannel",
37    "DeleteEvaluationResults",
38    "DeleteOrganizationConfigRule",
39    "DeleteOrganizationConformancePack",
40    "DeletePendingAggregationRequest",
41    "DeleteRemediationConfiguration",
42    "DeleteRemediationExceptions",
43    "DeleteResourceConfig",
44    "DeleteRetentionConfiguration",
45    "DeleteServiceLinkedConfigurationRecorder",
46    "DeleteStoredQuery",
47    "DeliverConfigSnapshot",
48    "DescribeAggregateComplianceByConfigRules",
49    "DescribeAggregateComplianceByConformancePacks",
50    "DescribeAggregationAuthorizations",
51    "DescribeComplianceByConfigRule",
52    "DescribeComplianceByResource",
53    "DescribeConfigRuleEvaluationStatus",
54    "DescribeConfigRules",
55    "DescribeConfigurationAggregatorSourcesStatus",
56    "DescribeConfigurationAggregators",
57    "DescribeConfigurationRecorderStatus",
58    "DescribeConfigurationRecorders",
59    "DescribeConformancePackCompliance",
60    "DescribeConformancePackStatus",
61    "DescribeConformancePacks",
62    "DescribeDeliveryChannelStatus",
63    "DescribeDeliveryChannels",
64    "DescribeOrganizationConfigRuleStatuses",
65    "DescribeOrganizationConfigRules",
66    "DescribeOrganizationConformancePackStatuses",
67    "DescribeOrganizationConformancePacks",
68    "DescribePendingAggregationRequests",
69    "DescribeRemediationConfigurations",
70    "DescribeRemediationExceptions",
71    "DescribeRemediationExecutionStatus",
72    "DescribeRetentionConfigurations",
73    "DisassociateResourceTypes",
74    "GetAggregateComplianceDetailsByConfigRule",
75    "GetAggregateConfigRuleComplianceSummary",
76    "GetAggregateConformancePackComplianceSummary",
77    "GetAggregateDiscoveredResourceCounts",
78    "GetAggregateResourceConfig",
79    "GetComplianceDetailsByConfigRule",
80    "GetComplianceDetailsByResource",
81    "GetComplianceSummaryByConfigRule",
82    "GetComplianceSummaryByResourceType",
83    "GetConformancePackComplianceDetails",
84    "GetConformancePackComplianceSummary",
85    "GetCustomRulePolicy",
86    "GetDiscoveredResourceCounts",
87    "GetOrganizationConfigRuleDetailedStatus",
88    "GetOrganizationConformancePackDetailedStatus",
89    "GetOrganizationCustomRulePolicy",
90    "GetResourceConfigHistory",
91    "GetResourceEvaluationSummary",
92    "GetStoredQuery",
93    "ListAggregateDiscoveredResources",
94    "ListConfigurationRecorders",
95    "ListConformancePackComplianceScores",
96    "ListDiscoveredResources",
97    "ListResourceEvaluations",
98    "ListStoredQueries",
99    "ListTagsForResource",
100    "PutAggregationAuthorization",
101    "PutConfigRule",
102    "PutConfigurationAggregator",
103    "PutConfigurationRecorder",
104    "PutConformancePack",
105    "PutDeliveryChannel",
106    "PutEvaluations",
107    "PutExternalEvaluation",
108    "PutOrganizationConfigRule",
109    "PutOrganizationConformancePack",
110    "PutRemediationConfigurations",
111    "PutRemediationExceptions",
112    "PutResourceConfig",
113    "PutRetentionConfiguration",
114    "PutServiceLinkedConfigurationRecorder",
115    "PutStoredQuery",
116    "SelectAggregateResourceConfig",
117    "SelectResourceConfig",
118    "StartConfigRulesEvaluation",
119    "StartConfigurationRecorder",
120    "StartRemediationExecution",
121    "StartResourceEvaluation",
122    "StopConfigurationRecorder",
123    "TagResource",
124    "UntagResource",
125];
126
127/// Actions that mutate persisted state and trigger a snapshot write.
128const MUTATING_ACTIONS: &[&str] = &[
129    "AssociateResourceTypes",
130    "DeleteAggregationAuthorization",
131    "DeleteConfigRule",
132    "DeleteConfigurationAggregator",
133    "DeleteConfigurationRecorder",
134    "DeleteConformancePack",
135    "DeleteDeliveryChannel",
136    "DeleteEvaluationResults",
137    "DeleteOrganizationConfigRule",
138    "DeleteOrganizationConformancePack",
139    "DeletePendingAggregationRequest",
140    "DeleteRemediationConfiguration",
141    "DeleteRemediationExceptions",
142    "DeleteResourceConfig",
143    "DeleteRetentionConfiguration",
144    "DeleteServiceLinkedConfigurationRecorder",
145    "DeleteStoredQuery",
146    "DisassociateResourceTypes",
147    "PutAggregationAuthorization",
148    "PutConfigRule",
149    "PutConfigurationAggregator",
150    "PutConfigurationRecorder",
151    "PutConformancePack",
152    "PutDeliveryChannel",
153    "PutEvaluations",
154    "PutExternalEvaluation",
155    "PutOrganizationConfigRule",
156    "PutOrganizationConformancePack",
157    "PutRemediationConfigurations",
158    "PutRemediationExceptions",
159    "PutResourceConfig",
160    "PutRetentionConfiguration",
161    "PutServiceLinkedConfigurationRecorder",
162    "PutStoredQuery",
163    "StartConfigRulesEvaluation",
164    "StartConfigurationRecorder",
165    "StartRemediationExecution",
166    "StartResourceEvaluation",
167    "StopConfigurationRecorder",
168    "TagResource",
169    "UntagResource",
170];
171
172pub struct ConfigService {
173    state: SharedConfigState,
174    snapshot_store: Option<Arc<dyn SnapshotStore>>,
175    snapshot_lock: Arc<AsyncMutex<()>>,
176    cross: CrossServiceStates,
177    lambda_state: Option<SharedLambdaState>,
178    container_runtime: Option<Arc<ContainerRuntime>>,
179    /// Monotonic counter bumped whenever recorded items or rules change. Lets
180    /// managed-rule evaluation skip recomputing when nothing changed since the
181    /// last evaluation (avoids re-running the full evaluation on every poll).
182    state_version: Arc<AtomicU64>,
183    /// The `state_version` at which managed-rule evaluation last ran, tracked
184    /// per account. A single global counter would let one account's evaluation
185    /// suppress every other account's — a second account whose rules never
186    /// evaluated would report stale / INSUFFICIENT_DATA forever. Keyed by
187    /// account id so each account re-evaluates independently.
188    last_eval_version: Arc<Mutex<HashMap<String, u64>>>,
189}
190
191impl ConfigService {
192    pub fn new(state: SharedConfigState) -> Self {
193        Self {
194            state,
195            snapshot_store: None,
196            snapshot_lock: Arc::new(AsyncMutex::new(())),
197            cross: CrossServiceStates::default(),
198            lambda_state: None,
199            container_runtime: None,
200            state_version: Arc::new(AtomicU64::new(1)),
201            last_eval_version: Arc::new(Mutex::new(HashMap::new())),
202        }
203    }
204
205    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
206        self.snapshot_store = Some(store);
207        self
208    }
209
210    pub fn with_cross_service(mut self, cross: CrossServiceStates) -> Self {
211        self.cross = cross;
212        self
213    }
214
215    pub fn with_lambda(
216        mut self,
217        lambda_state: SharedLambdaState,
218        runtime: Option<Arc<ContainerRuntime>>,
219    ) -> Self {
220        self.lambda_state = Some(lambda_state);
221        self.container_runtime = runtime;
222        self
223    }
224
225    pub fn shared_state(&self) -> SharedConfigState {
226        Arc::clone(&self.state)
227    }
228
229    async fn save_snapshot(&self) {
230        save_config_snapshot(
231            &self.state,
232            self.snapshot_store.clone(),
233            &self.snapshot_lock,
234        )
235        .await;
236    }
237
238    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
239        let store = self.snapshot_store.clone()?;
240        let state = self.state.clone();
241        let lock = self.snapshot_lock.clone();
242        Some(Arc::new(move || {
243            let state = state.clone();
244            let store = store.clone();
245            let lock = lock.clone();
246            Box::pin(async move {
247                save_config_snapshot(&state, Some(store), &lock).await;
248            })
249        }))
250    }
251
252    /// Whether at least one recorder is running for the account.
253    fn is_recording(&self, account_id: &str) -> bool {
254        let st = self.state.read();
255        st.account(account_id)
256            .map(|a| a.recorders.values().any(|r| r.recording))
257            .unwrap_or(false)
258    }
259
260    /// Bump the state version so cached managed-rule evaluation is recomputed.
261    fn bump_version(&self) {
262        self.state_version.fetch_add(1, Ordering::Relaxed);
263    }
264
265    /// Snapshot the live cross-service resources into recorded config items
266    /// when a recorder is running. Discovery runs under the other services'
267    /// read locks; the config write lock (which serializes config reads) is
268    /// taken only when discovery actually yields a change.
269    fn sync_recording(&self, account_id: &str, region: &str) {
270        if !self.is_recording(account_id) {
271            return;
272        }
273        // Discover under the cross-service read locks (no config lock held).
274        let discovered = validate::discover_all(&self.cross, account_id, region);
275        // Read-only check: skip the write lock entirely if nothing changed.
276        let needs = {
277            let st = self.state.read();
278            match st.account(account_id) {
279                Some(acc) => validate::sync_would_change(acc, &discovered),
280                None => !discovered.is_empty(),
281            }
282        };
283        if !needs {
284            return;
285        }
286        {
287            let mut st = self.state.write();
288            let account = st.account_mut(account_id);
289            validate::apply_recorded_items(account, discovered, account_id);
290        }
291        self.bump_version();
292    }
293
294    /// Run managed-rule evaluation for every AWS-managed rule and store the
295    /// results — but only when recorded items or rules have changed since the
296    /// last evaluation. Custom / policy rule results are left as recorded by
297    /// `PutEvaluations` / `PutExternalEvaluation`.
298    fn ensure_evaluated(&self, account_id: &str) {
299        let version = self.state_version.load(Ordering::Relaxed);
300        if self.last_eval_version.lock().get(account_id).copied() == Some(version) {
301            return;
302        }
303        let mut st = self.state.write();
304        let account = st.account_mut(account_id);
305        let rules: Vec<(String, String, Value, Value)> = account
306            .rules
307            .values()
308            .filter_map(|r| {
309                let owner = r.source.get("Owner").and_then(Value::as_str).unwrap_or("");
310                if owner != "AWS" {
311                    return None;
312                }
313                let sid = r
314                    .source
315                    .get("SourceIdentifier")
316                    .and_then(Value::as_str)?
317                    .to_string();
318                let params: Value = r
319                    .input_parameters
320                    .as_deref()
321                    .and_then(|p| serde_json::from_str(p).ok())
322                    .unwrap_or(Value::Null);
323                let scope = r.scope.clone().unwrap_or(Value::Null);
324                Some((r.name.clone(), sid, params, scope))
325            })
326            .collect();
327        for (rule_name, sid, params, scope) in rules {
328            let outcomes = validate::evaluate_managed_rule(&sid, &params, &scope, account);
329            let entry = account.evaluations.entry(rule_name.clone()).or_default();
330            entry.clear();
331            for o in &outcomes {
332                let key = resource_key(&o.resource_type, &o.resource_id);
333                entry.insert(key, validate::outcome_to_result(&rule_name, o));
334            }
335            if let Some(rule) = account.rules.get_mut(&rule_name) {
336                let now = Utc::now();
337                rule.last_successful_evaluation_time = Some(now);
338                rule.last_successful_invocation_time = Some(now);
339                if rule.first_activated_time.is_none() {
340                    rule.first_activated_time = Some(now);
341                }
342            }
343        }
344        drop(st);
345        self.last_eval_version
346            .lock()
347            .insert(account_id.to_string(), version);
348    }
349}
350
351impl Default for ConfigService {
352    fn default() -> Self {
353        Self::new(Arc::new(parking_lot::RwLock::new(ConfigAccounts::new())))
354    }
355}
356
357#[async_trait]
358impl AwsService for ConfigService {
359    fn service_name(&self) -> &str {
360        "config"
361    }
362
363    fn supported_actions(&self) -> &[&str] {
364        SUPPORTED_ACTIONS
365    }
366
367    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
368        let account = account_id(&req);
369        let region = region(&req);
370        let body = req.json_body();
371        let mutates = MUTATING_ACTIONS.contains(&req.action.as_str());
372
373        // Enforce the Smithy model's input constraints (required / length /
374        // range / enum) exactly as AWS does, before any business logic.
375        if let Err(msg) = crate::model_validate::validate_input(&req.action, &body) {
376            return Err(AwsServiceError::aws_error(
377                StatusCode::BAD_REQUEST,
378                "ValidationException",
379                msg,
380            ));
381        }
382
383        // Actions that read discovered resources refresh the recording first.
384        const RECORDING_READS: &[&str] = &[
385            "GetResourceConfigHistory",
386            "BatchGetResourceConfig",
387            "BatchGetAggregateResourceConfig",
388            "ListDiscoveredResources",
389            "ListAggregateDiscoveredResources",
390            "GetDiscoveredResourceCounts",
391            "GetAggregateDiscoveredResourceCounts",
392            "GetResourceConfig",
393            "GetAggregateResourceConfig",
394            "SelectResourceConfig",
395            "SelectAggregateResourceConfig",
396            "StartConfigRulesEvaluation",
397            "DescribeComplianceByConfigRule",
398            "DescribeComplianceByResource",
399            "GetComplianceDetailsByConfigRule",
400            "GetComplianceDetailsByResource",
401            "GetComplianceSummaryByConfigRule",
402            "GetComplianceSummaryByResourceType",
403            "DescribeConfigRuleEvaluationStatus",
404        ];
405        if RECORDING_READS.contains(&req.action.as_str()) {
406            self.sync_recording(&account, &region);
407        }
408
409        let result = match req.action.as_str() {
410            // ── Recorders ──
411            "PutConfigurationRecorder" => self.put_configuration_recorder(&account, &region, &body),
412            "DescribeConfigurationRecorders" => {
413                self.describe_configuration_recorders(&account, &body)
414            }
415            "DescribeConfigurationRecorderStatus" => {
416                self.describe_configuration_recorder_status(&account, &body)
417            }
418            "DeleteConfigurationRecorder" => self.delete_configuration_recorder(&account, &body),
419            "StartConfigurationRecorder" => self.set_recording(&account, &body, true),
420            "StopConfigurationRecorder" => self.set_recording(&account, &body, false),
421            "ListConfigurationRecorders" => self.list_configuration_recorders(&account, &body),
422            "PutServiceLinkedConfigurationRecorder" => {
423                self.put_service_linked_recorder(&account, &region, &body)
424            }
425            "DeleteServiceLinkedConfigurationRecorder" => {
426                self.delete_service_linked_recorder(&account, &body)
427            }
428            "AssociateResourceTypes" => self.associate_resource_types(&account, &body, true),
429            "DisassociateResourceTypes" => self.associate_resource_types(&account, &body, false),
430            // ── Delivery channels ──
431            "PutDeliveryChannel" => self.put_delivery_channel(&account, &body),
432            "DescribeDeliveryChannels" => self.describe_delivery_channels(&account, &body),
433            "DescribeDeliveryChannelStatus" => {
434                self.describe_delivery_channel_status(&account, &body)
435            }
436            "DeleteDeliveryChannel" => self.delete_delivery_channel(&account, &body),
437            "DeliverConfigSnapshot" => self.deliver_config_snapshot(&account, &body),
438            // ── Config items ──
439            "PutResourceConfig" => self.put_resource_config(&account, &region, &body),
440            "DeleteResourceConfig" => self.delete_resource_config(&account, &body),
441            "GetResourceConfigHistory" => self.get_resource_config_history(&account, &body),
442            "BatchGetResourceConfig" => self.batch_get_resource_config(&account, &body),
443            "ListDiscoveredResources" => self.list_discovered_resources(&account, &body),
444            "GetDiscoveredResourceCounts" => self.get_discovered_resource_counts(&account, &body),
445            // ── Config rules ──
446            "PutConfigRule" => self.put_config_rule(&account, &region, &body),
447            "DescribeConfigRules" => self.describe_config_rules(&account, &body),
448            "DeleteConfigRule" => self.delete_config_rule(&account, &body),
449            "DescribeConfigRuleEvaluationStatus" => {
450                self.ensure_evaluated(&account);
451                self.describe_config_rule_evaluation_status(&account, &body)
452            }
453            "StartConfigRulesEvaluation" => {
454                self.start_config_rules_evaluation(&account, &region, &body)
455                    .await
456            }
457            "PutEvaluations" => self.put_evaluations(&account, &body),
458            "PutExternalEvaluation" => self.put_external_evaluation(&account, &body),
459            "DeleteEvaluationResults" => self.delete_evaluation_results(&account, &body),
460            "GetCustomRulePolicy" => self.get_custom_rule_policy(&account, &body),
461            // ── Compliance ──
462            "DescribeComplianceByConfigRule" => {
463                self.ensure_evaluated(&account);
464                self.describe_compliance_by_config_rule(&account, &body)
465            }
466            "DescribeComplianceByResource" => {
467                self.ensure_evaluated(&account);
468                self.describe_compliance_by_resource(&account, &body)
469            }
470            "GetComplianceDetailsByConfigRule" => {
471                self.ensure_evaluated(&account);
472                self.get_compliance_details_by_config_rule(&account, &body)
473            }
474            "GetComplianceDetailsByResource" => {
475                self.ensure_evaluated(&account);
476                self.get_compliance_details_by_resource(&account, &body)
477            }
478            "GetComplianceSummaryByConfigRule" => {
479                self.ensure_evaluated(&account);
480                self.get_compliance_summary_by_config_rule(&account)
481            }
482            "GetComplianceSummaryByResourceType" => {
483                self.ensure_evaluated(&account);
484                self.get_compliance_summary_by_resource_type(&account, &body)
485            }
486            // ── Remediation ──
487            "PutRemediationConfigurations" => {
488                self.put_remediation_configurations(&account, &region, &body)
489            }
490            "DescribeRemediationConfigurations" => {
491                self.describe_remediation_configurations(&account, &body)
492            }
493            "DeleteRemediationConfiguration" => {
494                self.delete_remediation_configuration(&account, &body)
495            }
496            "PutRemediationExceptions" => self.put_remediation_exceptions(&account, &body),
497            "DescribeRemediationExceptions" => {
498                self.describe_remediation_exceptions(&account, &body)
499            }
500            "DeleteRemediationExceptions" => self.delete_remediation_exceptions(&account, &body),
501            "StartRemediationExecution" => self.start_remediation_execution(&account, &body),
502            "DescribeRemediationExecutionStatus" => {
503                self.describe_remediation_execution_status(&account, &body)
504            }
505            // ── Conformance packs ──
506            "PutConformancePack" => self.put_conformance_pack(&account, &region, &body),
507            "DescribeConformancePacks" => self.describe_conformance_packs(&account, &body),
508            "DeleteConformancePack" => self.delete_conformance_pack(&account, &body),
509            "DescribeConformancePackStatus" => {
510                self.describe_conformance_pack_status(&account, &body)
511            }
512            "DescribeConformancePackCompliance" => {
513                self.ensure_evaluated(&account);
514                self.describe_conformance_pack_compliance(&account, &body)
515            }
516            "GetConformancePackComplianceSummary" => {
517                self.ensure_evaluated(&account);
518                self.get_conformance_pack_compliance_summary(&account, &body)
519            }
520            "GetConformancePackComplianceDetails" => {
521                self.ensure_evaluated(&account);
522                self.get_conformance_pack_compliance_details(&account, &body)
523            }
524            "ListConformancePackComplianceScores" => {
525                self.ensure_evaluated(&account);
526                self.list_conformance_pack_compliance_scores(&account)
527            }
528            // ── Organization rules/packs ──
529            "PutOrganizationConfigRule" => {
530                self.put_organization_config_rule(&account, &region, &body)
531            }
532            "DescribeOrganizationConfigRules" => {
533                self.describe_organization_config_rules(&account, &body)
534            }
535            "DeleteOrganizationConfigRule" => self.delete_organization_config_rule(&account, &body),
536            "DescribeOrganizationConfigRuleStatuses" => {
537                self.describe_organization_config_rule_statuses(&account, &body)
538            }
539            "GetOrganizationConfigRuleDetailedStatus" => {
540                self.get_organization_config_rule_detailed_status(&account, &body)
541            }
542            "GetOrganizationCustomRulePolicy" => {
543                self.get_organization_custom_rule_policy(&account, &body)
544            }
545            "PutOrganizationConformancePack" => {
546                self.put_organization_conformance_pack(&account, &region, &body)
547            }
548            "DescribeOrganizationConformancePacks" => {
549                self.describe_organization_conformance_packs(&account, &body)
550            }
551            "DeleteOrganizationConformancePack" => {
552                self.delete_organization_conformance_pack(&account, &body)
553            }
554            "DescribeOrganizationConformancePackStatuses" => {
555                self.describe_organization_conformance_pack_statuses(&account, &body)
556            }
557            "GetOrganizationConformancePackDetailedStatus" => {
558                self.get_organization_conformance_pack_detailed_status(&account, &body)
559            }
560            // ── Aggregators ──
561            "PutConfigurationAggregator" => {
562                self.put_configuration_aggregator(&account, &region, &body)
563            }
564            "DescribeConfigurationAggregators" => {
565                self.describe_configuration_aggregators(&account, &body)
566            }
567            "DeleteConfigurationAggregator" => {
568                self.delete_configuration_aggregator(&account, &body)
569            }
570            "DescribeConfigurationAggregatorSourcesStatus" => {
571                self.describe_configuration_aggregator_sources_status(&account, &body)
572            }
573            "PutAggregationAuthorization" => {
574                self.put_aggregation_authorization(&account, &region, &body)
575            }
576            "DescribeAggregationAuthorizations" => {
577                self.describe_aggregation_authorizations(&account, &body)
578            }
579            "DeleteAggregationAuthorization" => {
580                self.delete_aggregation_authorization(&account, &body)
581            }
582            "DeletePendingAggregationRequest" => Ok(AwsResponse::ok_json(json!({}))),
583            "DescribePendingAggregationRequests" => Ok(AwsResponse::ok_json(
584                json!({ "PendingAggregationRequests": [] }),
585            )),
586            "BatchGetAggregateResourceConfig" => {
587                self.batch_get_aggregate_resource_config(&account, &body)
588            }
589            "GetAggregateResourceConfig" => self.get_aggregate_resource_config(&account, &body),
590            "ListAggregateDiscoveredResources" => {
591                self.list_aggregate_discovered_resources(&account, &body)
592            }
593            "GetAggregateDiscoveredResourceCounts" => {
594                self.get_aggregate_discovered_resource_counts(&account, &body)
595            }
596            "DescribeAggregateComplianceByConfigRules" => {
597                self.ensure_evaluated(&account);
598                self.describe_aggregate_compliance_by_config_rules(&account, &body)
599            }
600            "DescribeAggregateComplianceByConformancePacks" => {
601                self.describe_aggregate_compliance_by_conformance_packs(&account, &body)
602            }
603            "GetAggregateComplianceDetailsByConfigRule" => {
604                self.ensure_evaluated(&account);
605                self.get_aggregate_compliance_details_by_config_rule(&account, &body)
606            }
607            "GetAggregateConfigRuleComplianceSummary" => {
608                self.ensure_evaluated(&account);
609                self.get_aggregate_config_rule_compliance_summary(&account, &body)
610            }
611            "GetAggregateConformancePackComplianceSummary" => {
612                self.get_aggregate_conformance_pack_compliance_summary(&account, &body)
613            }
614            "SelectAggregateResourceConfig" => self.select_resource_config(&account, &body, true),
615            // ── Retention ──
616            "PutRetentionConfiguration" => self.put_retention_configuration(&account, &body),
617            "DescribeRetentionConfigurations" => {
618                self.describe_retention_configurations(&account, &body)
619            }
620            "DeleteRetentionConfiguration" => self.delete_retention_configuration(&account, &body),
621            // ── Stored queries ──
622            "PutStoredQuery" => self.put_stored_query(&account, &region, &body),
623            "GetStoredQuery" => self.get_stored_query(&account, &body),
624            "DeleteStoredQuery" => self.delete_stored_query(&account, &body),
625            "ListStoredQueries" => self.list_stored_queries(&account, &body),
626            // ── Resource evaluations ──
627            "StartResourceEvaluation" => self.start_resource_evaluation(&account, &body),
628            "GetResourceEvaluationSummary" => self.get_resource_evaluation_summary(&account, &body),
629            "ListResourceEvaluations" => self.list_resource_evaluations(&account, &body),
630            // ── Select ──
631            "SelectResourceConfig" => self.select_resource_config(&account, &body, false),
632            // ── Tags ──
633            "TagResource" => self.tag_resource(&account, &body),
634            "UntagResource" => self.untag_resource(&account, &body),
635            "ListTagsForResource" => self.list_tags_for_resource(&account, &body),
636            other => Err(AwsServiceError::action_not_implemented("config", other)),
637        };
638
639        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
640            // A successful mutation may change recorded items or rules, so
641            // managed-rule evaluation must recompute on the next read.
642            self.bump_version();
643            self.save_snapshot().await;
644        }
645        result
646    }
647}
648
649// ─── Recorders ───────────────────────────────────────────────────────────
650
651impl ConfigService {
652    fn put_configuration_recorder(
653        &self,
654        account: &str,
655        region: &str,
656        body: &Value,
657    ) -> Result<AwsResponse, AwsServiceError> {
658        let rec = body
659            .get("ConfigurationRecorder")
660            .filter(|v| v.is_object())
661            .ok_or_else(|| invalid("ConfigurationRecorder is required"))?;
662        let name = rec
663            .get("name")
664            .and_then(Value::as_str)
665            .unwrap_or("default")
666            .to_string();
667        let role_arn = rec
668            .get("roleARN")
669            .and_then(Value::as_str)
670            .unwrap_or("")
671            .to_string();
672        let mut st = self.state.write();
673        let acc = st.account_mut(account);
674        let existing = acc.recorders.get(&name);
675        let recorder = ConfigurationRecorder {
676            name: name.clone(),
677            role_arn,
678            recording_group: rec.get("recordingGroup").cloned(),
679            recording_mode: rec.get("recordingMode").cloned(),
680            arn: existing.and_then(|e| e.arn.clone()),
681            service_principal: existing.and_then(|e| e.service_principal.clone()),
682            recording: existing.map(|e| e.recording).unwrap_or(false),
683            last_start_time: existing.and_then(|e| e.last_start_time),
684            last_stop_time: existing.and_then(|e| e.last_stop_time),
685            last_status: existing
686                .map(|e| e.last_status.clone())
687                .unwrap_or_else(|| "PENDING".into()),
688            last_status_change_time: Some(Utc::now()),
689        };
690        let _ = region;
691        acc.recorders.insert(name, recorder);
692        Ok(AwsResponse::ok_json(json!({})))
693    }
694
695    fn recorder_json(r: &ConfigurationRecorder) -> Value {
696        let mut v = json!({
697            "name": r.name,
698            "roleARN": r.role_arn,
699        });
700        if let Some(g) = &r.recording_group {
701            v["recordingGroup"] = g.clone();
702        }
703        if let Some(m) = &r.recording_mode {
704            v["recordingMode"] = m.clone();
705        }
706        if let Some(a) = &r.arn {
707            v["arn"] = json!(a);
708        }
709        if let Some(sp) = &r.service_principal {
710            v["servicePrincipal"] = json!(sp);
711        }
712        v
713    }
714
715    fn describe_configuration_recorders(
716        &self,
717        account: &str,
718        body: &Value,
719    ) -> Result<AwsResponse, AwsServiceError> {
720        let names = string_list(body, "ConfigurationRecorderNames");
721        let st = self.state.read();
722        let list: Vec<Value> = st
723            .account(account)
724            .map(|a| {
725                a.recorders
726                    .values()
727                    .filter(|r| names.is_empty() || names.contains(&r.name))
728                    .map(Self::recorder_json)
729                    .collect()
730            })
731            .unwrap_or_default();
732        if !names.is_empty() {
733            for n in &names {
734                let found = st
735                    .account(account)
736                    .map(|a| a.recorders.contains_key(n))
737                    .unwrap_or(false);
738                if !found {
739                    return Err(no_such(
740                        "NoSuchConfigurationRecorderException",
741                        format!(
742                            "Cannot find configuration recorder with the specified name '{n}'."
743                        ),
744                    ));
745                }
746            }
747        }
748        Ok(AwsResponse::ok_json(
749            json!({ "ConfigurationRecorders": list }),
750        ))
751    }
752
753    fn describe_configuration_recorder_status(
754        &self,
755        account: &str,
756        body: &Value,
757    ) -> Result<AwsResponse, AwsServiceError> {
758        let names = string_list(body, "ConfigurationRecorderNames");
759        let st = self.state.read();
760        let list: Vec<Value> = st
761            .account(account)
762            .map(|a| {
763                a.recorders
764                    .values()
765                    .filter(|r| names.is_empty() || names.contains(&r.name))
766                    .map(|r| {
767                        json!({
768                            "name": r.name,
769                            "lastStartTime": r.last_start_time.map(|t| t.timestamp() as f64),
770                            "lastStopTime": r.last_stop_time.map(|t| t.timestamp() as f64),
771                            "recording": r.recording,
772                            "lastStatus": r.last_status,
773                            "lastStatusChangeTime": r.last_status_change_time.map(|t| t.timestamp() as f64),
774                        })
775                    })
776                    .collect()
777            })
778            .unwrap_or_default();
779        Ok(AwsResponse::ok_json(
780            json!({ "ConfigurationRecordersStatus": list }),
781        ))
782    }
783
784    fn delete_configuration_recorder(
785        &self,
786        account: &str,
787        body: &Value,
788    ) -> Result<AwsResponse, AwsServiceError> {
789        let name = require_str(body, "ConfigurationRecorderName")?;
790        let mut st = self.state.write();
791        let acc = st.account_mut(account);
792        if acc.recorders.remove(&name).is_none() {
793            return Err(no_such(
794                "NoSuchConfigurationRecorderException",
795                format!("Cannot find configuration recorder with the specified name '{name}'."),
796            ));
797        }
798        Ok(AwsResponse::ok_json(json!({})))
799    }
800
801    fn set_recording(
802        &self,
803        account: &str,
804        body: &Value,
805        recording: bool,
806    ) -> Result<AwsResponse, AwsServiceError> {
807        let name = require_str(body, "ConfigurationRecorderName")?;
808        let mut st = self.state.write();
809        let acc = st.account_mut(account);
810        let rec = acc.recorders.get_mut(&name).ok_or_else(|| {
811            no_such(
812                "NoSuchConfigurationRecorderException",
813                format!("Cannot find configuration recorder with the specified name '{name}'."),
814            )
815        })?;
816        rec.recording = recording;
817        let now = Utc::now();
818        if recording {
819            rec.last_start_time = Some(now);
820            rec.last_status = "SUCCESS".into();
821        } else {
822            rec.last_stop_time = Some(now);
823            rec.last_status = "PENDING".into();
824        }
825        rec.last_status_change_time = Some(now);
826        Ok(AwsResponse::ok_json(json!({})))
827    }
828
829    fn list_configuration_recorders(
830        &self,
831        account: &str,
832        body: &Value,
833    ) -> Result<AwsResponse, AwsServiceError> {
834        let st = self.state.read();
835        let list: Vec<Value> = st
836            .account(account)
837            .map(|a| {
838                a.recorders
839                    .values()
840                    .map(|r| {
841                        let mut v = json!({ "name": r.name, "recordingScope": "INTERNAL" });
842                        if let Some(arn) = &r.arn {
843                            v["arn"] = json!(arn);
844                        }
845                        if let Some(sp) = &r.service_principal {
846                            v["servicePrincipal"] = json!(sp);
847                        }
848                        v
849                    })
850                    .collect()
851            })
852            .unwrap_or_default();
853        Ok(paged_response(
854            "ConfigurationRecorderSummaries",
855            list,
856            body,
857            "NextToken",
858        ))
859    }
860
861    fn put_service_linked_recorder(
862        &self,
863        account: &str,
864        region: &str,
865        body: &Value,
866    ) -> Result<AwsResponse, AwsServiceError> {
867        let sp = body
868            .get("ServicePrincipal")
869            .and_then(Value::as_str)
870            .unwrap_or("config-conforms.amazonaws.com")
871            .to_string();
872        let name = format!(
873            "AWSConfigurationRecorderForServiceLinkedConfigurationRecorder-{}",
874            &Uuid::new_v4().to_string()[..8]
875        );
876        let arn = format!("arn:aws:config:{region}:{account}:configuration-recorder/{name}");
877        let mut st = self.state.write();
878        let acc = st.account_mut(account);
879        acc.recorders.insert(
880            name.clone(),
881            ConfigurationRecorder {
882                name: name.clone(),
883                role_arn: format!("arn:aws:iam::{account}:role/aws-service-role/config.amazonaws.com/AWSServiceRoleForConfig"),
884                recording_group: None,
885                recording_mode: None,
886                arn: Some(arn.clone()),
887                service_principal: Some(sp),
888                recording: true,
889                last_start_time: Some(Utc::now()),
890                last_stop_time: None,
891                last_status: "SUCCESS".into(),
892                last_status_change_time: Some(Utc::now()),
893            },
894        );
895        Ok(AwsResponse::ok_json(json!({ "Arn": arn, "Name": name })))
896    }
897
898    fn delete_service_linked_recorder(
899        &self,
900        account: &str,
901        body: &Value,
902    ) -> Result<AwsResponse, AwsServiceError> {
903        let sp = body
904            .get("ServicePrincipal")
905            .and_then(Value::as_str)
906            .unwrap_or_default()
907            .to_string();
908        let mut st = self.state.write();
909        let acc = st.account_mut(account);
910        let name = acc
911            .recorders
912            .values()
913            .find(|r| r.service_principal.as_deref() == Some(sp.as_str()))
914            .map(|r| r.name.clone());
915        let name = name.ok_or_else(|| {
916            no_such(
917                "NoSuchConfigurationRecorderException",
918                "No service-linked configuration recorder for that principal.",
919            )
920        })?;
921        let arn = acc
922            .recorders
923            .get(&name)
924            .and_then(|r| r.arn.clone())
925            .unwrap_or_default();
926        acc.recorders.remove(&name);
927        Ok(AwsResponse::ok_json(json!({ "Arn": arn, "Name": name })))
928    }
929
930    fn associate_resource_types(
931        &self,
932        account: &str,
933        body: &Value,
934        associate: bool,
935    ) -> Result<AwsResponse, AwsServiceError> {
936        let name = require_str(body, "ConfigurationRecorderArn")
937            .or_else(|_| require_str(body, "ConfigurationRecorderName"))?;
938        let types = string_list(body, "ResourceTypes");
939        let mut st = self.state.write();
940        let acc = st.account_mut(account);
941        // Match by ARN or by name.
942        let rec = acc
943            .recorders
944            .values_mut()
945            .find(|r| r.arn.as_deref() == Some(name.as_str()) || r.name == name)
946            .ok_or_else(|| {
947                no_such(
948                    "NoSuchConfigurationRecorderException",
949                    "Cannot find the specified configuration recorder.",
950                )
951            })?;
952        let mut group = rec.recording_group.clone().unwrap_or_else(|| json!({}));
953        let current = group
954            .get("resourceTypes")
955            .and_then(Value::as_array)
956            .cloned()
957            .unwrap_or_default();
958        let mut set: Vec<String> = current
959            .iter()
960            .filter_map(|v| v.as_str().map(String::from))
961            .collect();
962        if associate {
963            for t in types {
964                if !set.contains(&t) {
965                    set.push(t);
966                }
967            }
968        } else {
969            set.retain(|t| !types.contains(t));
970        }
971        group["allSupported"] = json!(false);
972        group["resourceTypes"] = json!(set);
973        rec.recording_group = Some(group);
974        let out = Self::recorder_json(rec);
975        Ok(AwsResponse::ok_json(
976            json!({ "ConfigurationRecorder": out }),
977        ))
978    }
979}
980
981// ─── Delivery channels ────────────────────────────────────────────────────
982
983impl ConfigService {
984    fn put_delivery_channel(
985        &self,
986        account: &str,
987        body: &Value,
988    ) -> Result<AwsResponse, AwsServiceError> {
989        let ch = body
990            .get("DeliveryChannel")
991            .filter(|v| v.is_object())
992            .ok_or_else(|| invalid("DeliveryChannel is required"))?;
993        let name = ch
994            .get("name")
995            .and_then(Value::as_str)
996            .unwrap_or("default")
997            .to_string();
998        let bucket = ch
999            .get("s3BucketName")
1000            .and_then(Value::as_str)
1001            .map(String::from);
1002        match &bucket {
1003            None => {
1004                return Err(no_such(
1005                    "NoSuchBucketException",
1006                    "Cannot find a S3 bucket with an empty bucket name.",
1007                ));
1008            }
1009            Some(name) => {
1010                // Validate the referenced bucket actually exists (matches AWS,
1011                // which rejects a delivery channel pointing at a missing
1012                // bucket). Skipped when S3 is not wired.
1013                if validate::s3_bucket_exists(&self.cross, name) == Some(false) {
1014                    return Err(no_such(
1015                        "NoSuchBucketException",
1016                        format!("Cannot find a S3 bucket with the bucket name '{name}'."),
1017                    ));
1018                }
1019            }
1020        }
1021        let channel = DeliveryChannel {
1022            name: name.clone(),
1023            s3_bucket_name: bucket,
1024            s3_key_prefix: ch
1025                .get("s3KeyPrefix")
1026                .and_then(Value::as_str)
1027                .map(String::from),
1028            s3_kms_key_arn: ch
1029                .get("s3KmsKeyArn")
1030                .and_then(Value::as_str)
1031                .map(String::from),
1032            sns_topic_arn: ch
1033                .get("snsTopicARN")
1034                .and_then(Value::as_str)
1035                .map(String::from),
1036            config_snapshot_delivery_properties: ch
1037                .get("configSnapshotDeliveryProperties")
1038                .cloned(),
1039            last_status: "SUCCESS".into(),
1040        };
1041        let mut st = self.state.write();
1042        st.account_mut(account)
1043            .delivery_channels
1044            .insert(name, channel);
1045        Ok(AwsResponse::ok_json(json!({})))
1046    }
1047
1048    fn channel_json(c: &DeliveryChannel) -> Value {
1049        let mut v = json!({ "name": c.name });
1050        if let Some(b) = &c.s3_bucket_name {
1051            v["s3BucketName"] = json!(b);
1052        }
1053        if let Some(p) = &c.s3_key_prefix {
1054            v["s3KeyPrefix"] = json!(p);
1055        }
1056        if let Some(k) = &c.s3_kms_key_arn {
1057            v["s3KmsKeyArn"] = json!(k);
1058        }
1059        if let Some(s) = &c.sns_topic_arn {
1060            v["snsTopicARN"] = json!(s);
1061        }
1062        if let Some(p) = &c.config_snapshot_delivery_properties {
1063            v["configSnapshotDeliveryProperties"] = p.clone();
1064        }
1065        v
1066    }
1067
1068    fn describe_delivery_channels(
1069        &self,
1070        account: &str,
1071        body: &Value,
1072    ) -> Result<AwsResponse, AwsServiceError> {
1073        let names = string_list(body, "DeliveryChannelNames");
1074        let st = self.state.read();
1075        let list: Vec<Value> = st
1076            .account(account)
1077            .map(|a| {
1078                a.delivery_channels
1079                    .values()
1080                    .filter(|c| names.is_empty() || names.contains(&c.name))
1081                    .map(Self::channel_json)
1082                    .collect()
1083            })
1084            .unwrap_or_default();
1085        Ok(AwsResponse::ok_json(json!({ "DeliveryChannels": list })))
1086    }
1087
1088    fn describe_delivery_channel_status(
1089        &self,
1090        account: &str,
1091        body: &Value,
1092    ) -> Result<AwsResponse, AwsServiceError> {
1093        let names = string_list(body, "DeliveryChannelNames");
1094        let now = Utc::now().timestamp() as f64;
1095        let st = self.state.read();
1096        let list: Vec<Value> = st
1097            .account(account)
1098            .map(|a| {
1099                a.delivery_channels
1100                    .values()
1101                    .filter(|c| names.is_empty() || names.contains(&c.name))
1102                    .map(|c| {
1103                        json!({
1104                            "name": c.name,
1105                            "configSnapshotDeliveryInfo": { "lastStatus": "SUCCESS", "lastSuccessfulTime": now },
1106                            "configHistoryDeliveryInfo": { "lastStatus": "SUCCESS", "lastSuccessfulTime": now },
1107                            "configStreamDeliveryInfo": { "lastStatus": "SUCCESS", "lastStatusChangeTime": now },
1108                        })
1109                    })
1110                    .collect()
1111            })
1112            .unwrap_or_default();
1113        Ok(AwsResponse::ok_json(
1114            json!({ "DeliveryChannelsStatus": list }),
1115        ))
1116    }
1117
1118    fn delete_delivery_channel(
1119        &self,
1120        account: &str,
1121        body: &Value,
1122    ) -> Result<AwsResponse, AwsServiceError> {
1123        let name = require_str(body, "DeliveryChannelName")?;
1124        let mut st = self.state.write();
1125        let acc = st.account_mut(account);
1126        if acc.delivery_channels.remove(&name).is_none() {
1127            return Err(no_such(
1128                "NoSuchDeliveryChannelException",
1129                format!("Cannot find delivery channel with specified name '{name}'."),
1130            ));
1131        }
1132        Ok(AwsResponse::ok_json(json!({})))
1133    }
1134
1135    fn deliver_config_snapshot(
1136        &self,
1137        account: &str,
1138        body: &Value,
1139    ) -> Result<AwsResponse, AwsServiceError> {
1140        let name = require_str(body, "deliveryChannelName")?;
1141        let st = self.state.read();
1142        let exists = st
1143            .account(account)
1144            .map(|a| a.delivery_channels.contains_key(&name))
1145            .unwrap_or(false);
1146        if !exists {
1147            return Err(no_such(
1148                "NoSuchDeliveryChannelException",
1149                format!("Cannot find delivery channel with specified name '{name}'."),
1150            ));
1151        }
1152        Ok(AwsResponse::ok_json(
1153            json!({ "configSnapshotId": Uuid::new_v4().to_string() }),
1154        ))
1155    }
1156}
1157
1158// ─── Config items ─────────────────────────────────────────────────────────
1159
1160impl ConfigService {
1161    fn config_item_json(ci: &ConfigurationItem) -> Value {
1162        json!({
1163            "version": ci.version,
1164            "accountId": ci.account_id,
1165            "configurationItemCaptureTime": ci.configuration_item_capture_time.timestamp() as f64,
1166            "configurationItemStatus": ci.configuration_item_status,
1167            "configurationStateId": ci.configuration_state_id,
1168            "arn": ci.arn,
1169            "resourceType": ci.resource_type,
1170            "resourceId": ci.resource_id,
1171            "resourceName": ci.resource_name,
1172            "awsRegion": ci.aws_region,
1173            "availabilityZone": ci.availability_zone,
1174            "resourceCreationTime": ci.resource_creation_time.map(|t| t.timestamp() as f64),
1175            "tags": ci.tags,
1176            "relatedEvents": [],
1177            "relationships": [],
1178            "configuration": ci.configuration,
1179            "supplementaryConfiguration": ci.supplementary_configuration,
1180        })
1181    }
1182
1183    fn base_config_item_json(ci: &ConfigurationItem) -> Value {
1184        json!({
1185            "version": ci.version,
1186            "accountId": ci.account_id,
1187            "configurationItemCaptureTime": ci.configuration_item_capture_time.timestamp() as f64,
1188            "configurationItemStatus": ci.configuration_item_status,
1189            "configurationStateId": ci.configuration_state_id,
1190            "arn": ci.arn,
1191            "resourceType": ci.resource_type,
1192            "resourceId": ci.resource_id,
1193            "resourceName": ci.resource_name,
1194            "awsRegion": ci.aws_region,
1195            "availabilityZone": ci.availability_zone,
1196            "resourceCreationTime": ci.resource_creation_time.map(|t| t.timestamp() as f64),
1197            "configuration": ci.configuration,
1198            "supplementaryConfiguration": ci.supplementary_configuration,
1199        })
1200    }
1201
1202    fn put_resource_config(
1203        &self,
1204        account: &str,
1205        region: &str,
1206        body: &Value,
1207    ) -> Result<AwsResponse, AwsServiceError> {
1208        let resource_type = require_str(body, "ResourceType")?;
1209        let resource_id = require_str(body, "ResourceId")?;
1210        require_str(body, "SchemaVersionId")?;
1211        let configuration = require_str(body, "Configuration")?;
1212        let resource_name = body
1213            .get("ResourceName")
1214            .and_then(Value::as_str)
1215            .map(String::from);
1216        let tags: std::collections::BTreeMap<String, String> = body
1217            .get("Tags")
1218            .and_then(Value::as_object)
1219            .map(|m| {
1220                m.iter()
1221                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1222                    .collect()
1223            })
1224            .unwrap_or_default();
1225        let now = Utc::now();
1226        let mut st = self.state.write();
1227        let acc = st.account_mut(account);
1228        let key = resource_key(&resource_type, &resource_id);
1229        let history = acc.config_items.entry(key).or_default();
1230        let state_id = (history.len() as u64 + 1).to_string();
1231        history.push(ConfigurationItem {
1232            version: "1.3".into(),
1233            account_id: account.to_string(),
1234            configuration_item_capture_time: now,
1235            configuration_item_status: if history.is_empty() {
1236                "ResourceDiscovered".into()
1237            } else {
1238                "OK".into()
1239            },
1240            configuration_state_id: state_id,
1241            arn: format!(
1242                "arn:aws:config:{region}:{account}:resource/{resource_type}/{resource_id}"
1243            ),
1244            resource_type,
1245            resource_id,
1246            resource_name,
1247            aws_region: region.to_string(),
1248            availability_zone: "Not Applicable".into(),
1249            resource_creation_time: Some(now),
1250            tags,
1251            configuration,
1252            supplementary_configuration: Default::default(),
1253            externally_recorded: true,
1254        });
1255        Ok(AwsResponse::ok_json(json!({})))
1256    }
1257
1258    fn delete_resource_config(
1259        &self,
1260        account: &str,
1261        body: &Value,
1262    ) -> Result<AwsResponse, AwsServiceError> {
1263        let resource_type = require_str(body, "ResourceType")?;
1264        let resource_id = require_str(body, "ResourceId")?;
1265        let mut st = self.state.write();
1266        let acc = st.account_mut(account);
1267        let key = resource_key(&resource_type, &resource_id);
1268        if let Some(history) = acc.config_items.get_mut(&key) {
1269            if let Some(last) = history.last().cloned() {
1270                let state_id = (history.len() as u64 + 1).to_string();
1271                history.push(ConfigurationItem {
1272                    configuration_item_capture_time: Utc::now(),
1273                    configuration_item_status: "ResourceDeleted".into(),
1274                    configuration_state_id: state_id,
1275                    ..last
1276                });
1277            }
1278        }
1279        Ok(AwsResponse::ok_json(json!({})))
1280    }
1281
1282    fn get_resource_config_history(
1283        &self,
1284        account: &str,
1285        body: &Value,
1286    ) -> Result<AwsResponse, AwsServiceError> {
1287        let resource_type = require_str(body, "resourceType")?;
1288        let resource_id = require_str(body, "resourceId")?;
1289        // Optional time window (epoch seconds). `laterTime` is the inclusive
1290        // upper bound, `earlierTime` the inclusive lower bound.
1291        let later = body.get("laterTime").and_then(Value::as_f64);
1292        let earlier = body.get("earlierTime").and_then(Value::as_f64);
1293        // Default order is Reverse (newest first), matching AWS.
1294        let forward = body
1295            .get("chronologicalOrder")
1296            .and_then(Value::as_str)
1297            .map(|o| o.eq_ignore_ascii_case("Forward"))
1298            .unwrap_or(false);
1299        let st = self.state.read();
1300        let key = resource_key(&resource_type, &resource_id);
1301        let history = st.account(account).and_then(|a| a.config_items.get(&key));
1302        let Some(history) = history else {
1303            return Err(no_such(
1304                "ResourceNotDiscoveredException",
1305                format!("Resource {resource_id} of type {resource_type} is not discovered."),
1306            ));
1307        };
1308        // Stored oldest-first; select those inside the window.
1309        let mut selected: Vec<&ConfigurationItem> = history
1310            .iter()
1311            .filter(|ci| {
1312                let ts = ci.configuration_item_capture_time.timestamp() as f64;
1313                later.map(|l| ts <= l).unwrap_or(true) && earlier.map(|e| ts >= e).unwrap_or(true)
1314            })
1315            .collect();
1316        if !forward {
1317            selected.reverse();
1318        }
1319        let items: Vec<Value> = selected.into_iter().map(Self::config_item_json).collect();
1320        let (page, next) = paginate(items, body);
1321        let mut out = json!({ "configurationItems": page });
1322        if let Some(t) = next {
1323            out["nextToken"] = json!(t);
1324        }
1325        Ok(AwsResponse::ok_json(out))
1326    }
1327
1328    fn batch_get_resource_config(
1329        &self,
1330        account: &str,
1331        body: &Value,
1332    ) -> Result<AwsResponse, AwsServiceError> {
1333        let keys = body
1334            .get("resourceKeys")
1335            .and_then(Value::as_array)
1336            .cloned()
1337            .unwrap_or_default();
1338        let st = self.state.read();
1339        let acc = st.account(account);
1340        let mut items = Vec::new();
1341        let mut unprocessed = Vec::new();
1342        for k in keys {
1343            let rt = k
1344                .get("resourceType")
1345                .and_then(Value::as_str)
1346                .unwrap_or_default();
1347            let rid = k
1348                .get("resourceId")
1349                .and_then(Value::as_str)
1350                .unwrap_or_default();
1351            let key = resource_key(rt, rid);
1352            match acc.and_then(|a| a.config_items.get(&key)).and_then(|h| {
1353                h.iter()
1354                    .rev()
1355                    .find(|ci| !ci.configuration_item_status.starts_with("ResourceDeleted"))
1356            }) {
1357                Some(ci) => items.push(Self::base_config_item_json(ci)),
1358                None => unprocessed.push(k),
1359            }
1360        }
1361        Ok(AwsResponse::ok_json(
1362            json!({ "baseConfigurationItems": items, "unprocessedResourceKeys": unprocessed }),
1363        ))
1364    }
1365
1366    fn list_discovered_resources(
1367        &self,
1368        account: &str,
1369        body: &Value,
1370    ) -> Result<AwsResponse, AwsServiceError> {
1371        let resource_type = require_str(body, "resourceType")?;
1372        let ids = string_list(body, "resourceIds");
1373        let include_deleted = body
1374            .get("includeDeletedResources")
1375            .and_then(Value::as_bool)
1376            .unwrap_or(false);
1377        let st = self.state.read();
1378        let mut list = Vec::new();
1379        if let Some(acc) = st.account(account) {
1380            for (key, history) in &acc.config_items {
1381                let Some((rt, _)) = key.split_once('\u{1}') else {
1382                    continue;
1383                };
1384                if rt != resource_type {
1385                    continue;
1386                }
1387                let Some(ci) = history.last() else { continue };
1388                let deleted = ci.configuration_item_status.starts_with("ResourceDeleted");
1389                if deleted && !include_deleted {
1390                    continue;
1391                }
1392                if !ids.is_empty() && !ids.contains(&ci.resource_id) {
1393                    continue;
1394                }
1395                let mut ident = json!({
1396                    "resourceType": ci.resource_type,
1397                    "resourceId": ci.resource_id,
1398                    "resourceName": ci.resource_name,
1399                });
1400                if deleted {
1401                    ident["resourceDeletionTime"] =
1402                        json!(ci.configuration_item_capture_time.timestamp() as f64);
1403                }
1404                list.push(ident);
1405            }
1406        }
1407        let (page, next) = paginate(list, body);
1408        let mut out = json!({ "resourceIdentifiers": page });
1409        if let Some(t) = next {
1410            out["nextToken"] = json!(t);
1411        }
1412        Ok(AwsResponse::ok_json(out))
1413    }
1414
1415    fn get_discovered_resource_counts(
1416        &self,
1417        account: &str,
1418        body: &Value,
1419    ) -> Result<AwsResponse, AwsServiceError> {
1420        let filter = string_list(body, "resourceTypes");
1421        let st = self.state.read();
1422        let mut counts: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
1423        if let Some(acc) = st.account(account) {
1424            for (key, history) in &acc.config_items {
1425                let Some((rt, _)) = key.split_once('\u{1}') else {
1426                    continue;
1427                };
1428                if !filter.is_empty() && !filter.contains(&rt.to_string()) {
1429                    continue;
1430                }
1431                let deleted = history
1432                    .last()
1433                    .map(|ci| ci.configuration_item_status.starts_with("ResourceDeleted"))
1434                    .unwrap_or(true);
1435                if deleted {
1436                    continue;
1437                }
1438                *counts.entry(rt.to_string()).or_insert(0) += 1;
1439            }
1440        }
1441        let total: i64 = counts.values().sum();
1442        let list: Vec<Value> = counts
1443            .into_iter()
1444            .map(|(t, c)| json!({ "resourceType": t, "count": c }))
1445            .collect();
1446        let (page, next) = paginate(list, body);
1447        let mut out = json!({ "totalDiscoveredResources": total, "resourceCounts": page });
1448        if let Some(t) = next {
1449            out["nextToken"] = json!(t);
1450        }
1451        Ok(AwsResponse::ok_json(out))
1452    }
1453}
1454
1455// ─── Config rules + evaluation ─────────────────────────────────────────────
1456
1457impl ConfigService {
1458    fn put_config_rule(
1459        &self,
1460        account: &str,
1461        region: &str,
1462        body: &Value,
1463    ) -> Result<AwsResponse, AwsServiceError> {
1464        let rule = body
1465            .get("ConfigRule")
1466            .filter(|v| v.is_object())
1467            .ok_or_else(|| invalid("ConfigRule is required"))?;
1468        let name = rule
1469            .get("ConfigRuleName")
1470            .and_then(Value::as_str)
1471            .ok_or_else(|| invalid("ConfigRuleName is required"))?
1472            .to_string();
1473        let source = rule
1474            .get("Source")
1475            .cloned()
1476            .ok_or_else(|| invalid("Source is required"))?;
1477        let owner = source
1478            .get("Owner")
1479            .and_then(Value::as_str)
1480            .unwrap_or_default();
1481        if !["AWS", "CUSTOM_LAMBDA", "CUSTOM_POLICY"].contains(&owner) {
1482            return Err(invalid(format!("Invalid Source.Owner '{owner}'")));
1483        }
1484        if owner == "CUSTOM_LAMBDA"
1485            && source
1486                .get("SourceIdentifier")
1487                .and_then(Value::as_str)
1488                .unwrap_or_default()
1489                .is_empty()
1490        {
1491            return Err(invalid(
1492                "SourceIdentifier (Lambda ARN) is required for CUSTOM_LAMBDA rules",
1493            ));
1494        }
1495        let now = Utc::now();
1496        let mut st = self.state.write();
1497        let acc = st.account_mut(account);
1498        let existing = acc.rules.get(&name);
1499        let arn = existing.map(|r| r.arn.clone()).unwrap_or_else(|| {
1500            format!(
1501                "arn:aws:config:{region}:{account}:config-rule/config-rule-{}",
1502                short_id()
1503            )
1504        });
1505        let rule_id = existing
1506            .map(|r| r.rule_id.clone())
1507            .unwrap_or_else(|| format!("config-rule-{}", short_id()));
1508        let cfg_rule = ConfigRule {
1509            name: name.clone(),
1510            arn,
1511            rule_id,
1512            description: rule
1513                .get("Description")
1514                .and_then(Value::as_str)
1515                .map(String::from),
1516            scope: rule.get("Scope").cloned(),
1517            source,
1518            input_parameters: rule
1519                .get("InputParameters")
1520                .and_then(Value::as_str)
1521                .map(String::from),
1522            maximum_execution_frequency: rule
1523                .get("MaximumExecutionFrequency")
1524                .and_then(Value::as_str)
1525                .map(String::from),
1526            state: "ACTIVE".into(),
1527            created_by: rule
1528                .get("CreatedBy")
1529                .and_then(Value::as_str)
1530                .map(String::from),
1531            evaluation_modes: rule.get("EvaluationModes").cloned(),
1532            last_updated: now,
1533            first_activated_time: existing.and_then(|r| r.first_activated_time),
1534            last_successful_evaluation_time: existing
1535                .and_then(|r| r.last_successful_evaluation_time),
1536            last_successful_invocation_time: existing
1537                .and_then(|r| r.last_successful_invocation_time),
1538        };
1539        acc.rules.insert(name.clone(), cfg_rule);
1540        let arn_for_tags = acc.rules[&name].arn.clone();
1541        let tags = parse_create_tags(body);
1542        if !tags.is_empty() {
1543            let entry = acc.tags.entry(arn_for_tags).or_default();
1544            for (k, v) in tags {
1545                entry.insert(k, v);
1546            }
1547        }
1548        Ok(AwsResponse::ok_json(json!({})))
1549    }
1550
1551    fn rule_json(r: &ConfigRule) -> Value {
1552        let mut v = json!({
1553            "ConfigRuleName": r.name,
1554            "ConfigRuleArn": r.arn,
1555            "ConfigRuleId": r.rule_id,
1556            "Source": r.source,
1557            "ConfigRuleState": r.state,
1558        });
1559        if let Some(d) = &r.description {
1560            v["Description"] = json!(d);
1561        }
1562        if let Some(s) = &r.scope {
1563            v["Scope"] = s.clone();
1564        }
1565        if let Some(p) = &r.input_parameters {
1566            v["InputParameters"] = json!(p);
1567        }
1568        if let Some(f) = &r.maximum_execution_frequency {
1569            v["MaximumExecutionFrequency"] = json!(f);
1570        }
1571        if let Some(c) = &r.created_by {
1572            v["CreatedBy"] = json!(c);
1573        }
1574        if let Some(m) = &r.evaluation_modes {
1575            v["EvaluationModes"] = m.clone();
1576        }
1577        v
1578    }
1579
1580    fn describe_config_rules(
1581        &self,
1582        account: &str,
1583        body: &Value,
1584    ) -> Result<AwsResponse, AwsServiceError> {
1585        let names = string_list(body, "ConfigRuleNames");
1586        let st = self.state.read();
1587        let list: Vec<Value> = st
1588            .account(account)
1589            .map(|a| {
1590                a.rules
1591                    .values()
1592                    .filter(|r| names.is_empty() || names.contains(&r.name))
1593                    .map(Self::rule_json)
1594                    .collect()
1595            })
1596            .unwrap_or_default();
1597        if !names.is_empty() {
1598            for n in &names {
1599                if !st
1600                    .account(account)
1601                    .map(|a| a.rules.contains_key(n))
1602                    .unwrap_or(false)
1603                {
1604                    return Err(no_such(
1605                        "NoSuchConfigRuleException",
1606                        format!("The ConfigRule '{n}' provided in the request is invalid."),
1607                    ));
1608                }
1609            }
1610        }
1611        Ok(paged_response("ConfigRules", list, body, "NextToken"))
1612    }
1613
1614    fn delete_config_rule(
1615        &self,
1616        account: &str,
1617        body: &Value,
1618    ) -> Result<AwsResponse, AwsServiceError> {
1619        let name = require_str(body, "ConfigRuleName")?;
1620        let mut st = self.state.write();
1621        let acc = st.account_mut(account);
1622        if acc.rules.remove(&name).is_none() {
1623            return Err(no_such(
1624                "NoSuchConfigRuleException",
1625                format!("The ConfigRule '{name}' provided in the request is invalid."),
1626            ));
1627        }
1628        acc.evaluations.remove(&name);
1629        acc.remediation_configs.remove(&name);
1630        acc.remediation_executions
1631            .retain(|_, e| e.config_rule_name != name);
1632        Ok(AwsResponse::ok_json(json!({})))
1633    }
1634
1635    fn describe_config_rule_evaluation_status(
1636        &self,
1637        account: &str,
1638        body: &Value,
1639    ) -> Result<AwsResponse, AwsServiceError> {
1640        let names = string_list(body, "ConfigRuleNames");
1641        let st = self.state.read();
1642        let list: Vec<Value> = st
1643            .account(account)
1644            .map(|a| {
1645                a.rules
1646                    .values()
1647                    .filter(|r| names.is_empty() || names.contains(&r.name))
1648                    .map(|r| {
1649                        json!({
1650                            "ConfigRuleName": r.name,
1651                            "ConfigRuleArn": r.arn,
1652                            "ConfigRuleId": r.rule_id,
1653                            "LastSuccessfulInvocationTime": r.last_successful_invocation_time.map(|t| t.timestamp() as f64),
1654                            "LastSuccessfulEvaluationTime": r.last_successful_evaluation_time.map(|t| t.timestamp() as f64),
1655                            "FirstActivatedTime": r.first_activated_time.map(|t| t.timestamp() as f64),
1656                            "FirstEvaluationStarted": r.first_activated_time.is_some(),
1657                        })
1658                    })
1659                    .collect()
1660            })
1661            .unwrap_or_default();
1662        Ok(paged_response(
1663            "ConfigRulesEvaluationStatus",
1664            list,
1665            body,
1666            "NextToken",
1667        ))
1668    }
1669
1670    async fn start_config_rules_evaluation(
1671        &self,
1672        account: &str,
1673        _region: &str,
1674        body: &Value,
1675    ) -> Result<AwsResponse, AwsServiceError> {
1676        self.ensure_evaluated(account);
1677        // For custom-Lambda rules, invoke the referenced function.
1678        let names = string_list(body, "ConfigRuleNames");
1679        let custom_rules: Vec<(String, String, Option<String>)> = {
1680            let st = self.state.read();
1681            st.account(account)
1682                .map(|a| {
1683                    a.rules
1684                        .values()
1685                        .filter(|r| names.is_empty() || names.contains(&r.name))
1686                        .filter_map(|r| {
1687                            let owner = r.source.get("Owner").and_then(Value::as_str).unwrap_or("");
1688                            if owner != "CUSTOM_LAMBDA" {
1689                                return None;
1690                            }
1691                            let lambda_arn = r
1692                                .source
1693                                .get("SourceIdentifier")
1694                                .and_then(Value::as_str)?
1695                                .to_string();
1696                            Some((r.name.clone(), lambda_arn, r.input_parameters.clone()))
1697                        })
1698                        .collect()
1699                })
1700                .unwrap_or_default()
1701        };
1702        for (rule_name, lambda_arn, input_parameters) in custom_rules {
1703            self.invoke_custom_rule(
1704                account,
1705                &rule_name,
1706                &lambda_arn,
1707                input_parameters.as_deref(),
1708            )
1709            .await;
1710        }
1711        Ok(AwsResponse::ok_json(json!({})))
1712    }
1713
1714    /// Invoke a custom-Lambda config rule's function with a Config invoking
1715    /// event. If the function returns an array of evaluations synchronously,
1716    /// record them; otherwise the function is expected to call PutEvaluations
1717    /// with the supplied resultToken.
1718    async fn invoke_custom_rule(
1719        &self,
1720        account: &str,
1721        rule_name: &str,
1722        lambda_arn: &str,
1723        input_parameters: Option<&str>,
1724    ) {
1725        let (Some(lambda_state), Some(runtime)) =
1726            (self.lambda_state.clone(), self.container_runtime.clone())
1727        else {
1728            return;
1729        };
1730        let func_name = lambda_arn
1731            .rsplit(':')
1732            .next()
1733            .unwrap_or(lambda_arn)
1734            .to_string();
1735        let result_token = format!("{rule_name}#{}", Uuid::new_v4());
1736        let event = custom_rule_event(account, rule_name, input_parameters, &result_token);
1737        // Resolve the Lambda in the rule's own account (not just the default
1738        // account) so a custom rule in a non-default account finds its function.
1739        let resolved = {
1740            let accounts = lambda_state.read();
1741            accounts
1742                .get(account)
1743                .and_then(|state| state.functions.get(&func_name).cloned())
1744        };
1745        let Some(func) = resolved else {
1746            tracing::warn!(function = %func_name, account = %account, "Config custom rule Lambda not found");
1747            return;
1748        };
1749        let payload = event.to_string().into_bytes();
1750        match runtime.invoke(&func, &payload, &[]).await {
1751            Ok(resp) => {
1752                // If the function returned evaluations directly, record them.
1753                if let Ok(v) = serde_json::from_slice::<Value>(&resp) {
1754                    if let Some(arr) = v
1755                        .as_array()
1756                        .or_else(|| v.get("evaluations").and_then(Value::as_array))
1757                    {
1758                        self.record_evaluations(account, rule_name, arr);
1759                    }
1760                }
1761            }
1762            Err(e) => {
1763                tracing::warn!(function = %func_name, error = %e, "Config custom rule Lambda invocation failed")
1764            }
1765        }
1766    }
1767
1768    fn record_evaluations(&self, account: &str, rule_name: &str, evals: &[Value]) {
1769        let now = Utc::now();
1770        let mut st = self.state.write();
1771        let acc = st.account_mut(account);
1772        let entry = acc.evaluations.entry(rule_name.to_string()).or_default();
1773        for e in evals {
1774            let rt = e
1775                .get("ComplianceResourceType")
1776                .and_then(Value::as_str)
1777                .unwrap_or_default()
1778                .to_string();
1779            let rid = e
1780                .get("ComplianceResourceId")
1781                .and_then(Value::as_str)
1782                .unwrap_or_default()
1783                .to_string();
1784            let ct = e
1785                .get("ComplianceType")
1786                .and_then(Value::as_str)
1787                .unwrap_or("INSUFFICIENT_DATA")
1788                .to_string();
1789            let key = resource_key(&rt, &rid);
1790            entry.insert(
1791                key,
1792                EvaluationResult {
1793                    resource_type: rt,
1794                    resource_id: rid,
1795                    rule_name: rule_name.to_string(),
1796                    compliance_type: ct,
1797                    annotation: e
1798                        .get("Annotation")
1799                        .and_then(Value::as_str)
1800                        .map(String::from),
1801                    result_recorded_time: now,
1802                    config_rule_invoked_time: now,
1803                    ordering_timestamp: now,
1804                },
1805            );
1806        }
1807        if let Some(rule) = acc.rules.get_mut(rule_name) {
1808            rule.last_successful_evaluation_time = Some(now);
1809            rule.last_successful_invocation_time = Some(now);
1810            if rule.first_activated_time.is_none() {
1811                rule.first_activated_time = Some(now);
1812            }
1813        }
1814    }
1815
1816    fn put_evaluations(&self, account: &str, body: &Value) -> Result<AwsResponse, AwsServiceError> {
1817        let token = require_str(body, "ResultToken")?;
1818        let rule_name = token.split('#').next().unwrap_or(&token).to_string();
1819        let evals = body
1820            .get("Evaluations")
1821            .and_then(Value::as_array)
1822            .cloned()
1823            .unwrap_or_default();
1824        let test_mode = body
1825            .get("TestMode")
1826            .and_then(Value::as_bool)
1827            .unwrap_or(false);
1828        if !test_mode {
1829            let now = Utc::now();
1830            let mut st = self.state.write();
1831            let acc = st.account_mut(account);
1832            let entry = acc.evaluations.entry(rule_name.clone()).or_default();
1833            for e in &evals {
1834                let rt = e
1835                    .get("ComplianceResourceType")
1836                    .and_then(Value::as_str)
1837                    .unwrap_or_default()
1838                    .to_string();
1839                let rid = e
1840                    .get("ComplianceResourceId")
1841                    .and_then(Value::as_str)
1842                    .unwrap_or_default()
1843                    .to_string();
1844                let ct = e
1845                    .get("ComplianceType")
1846                    .and_then(Value::as_str)
1847                    .unwrap_or("INSUFFICIENT_DATA")
1848                    .to_string();
1849                let key = resource_key(&rt, &rid);
1850                entry.insert(
1851                    key,
1852                    EvaluationResult {
1853                        resource_type: rt,
1854                        resource_id: rid,
1855                        rule_name: rule_name.clone(),
1856                        compliance_type: ct,
1857                        annotation: e
1858                            .get("Annotation")
1859                            .and_then(Value::as_str)
1860                            .map(String::from),
1861                        result_recorded_time: now,
1862                        config_rule_invoked_time: now,
1863                        ordering_timestamp: now,
1864                    },
1865                );
1866            }
1867        }
1868        Ok(AwsResponse::ok_json(json!({ "FailedEvaluations": [] })))
1869    }
1870
1871    fn put_external_evaluation(
1872        &self,
1873        account: &str,
1874        body: &Value,
1875    ) -> Result<AwsResponse, AwsServiceError> {
1876        let rule_name = require_str(body, "ConfigRuleName")?;
1877        let e = body
1878            .get("ExternalEvaluation")
1879            .cloned()
1880            .ok_or_else(|| invalid("ExternalEvaluation is required"))?;
1881        let rt = e
1882            .get("ComplianceResourceType")
1883            .and_then(Value::as_str)
1884            .unwrap_or_default()
1885            .to_string();
1886        let rid = e
1887            .get("ComplianceResourceId")
1888            .and_then(Value::as_str)
1889            .unwrap_or_default()
1890            .to_string();
1891        let ct = e
1892            .get("ComplianceType")
1893            .and_then(Value::as_str)
1894            .unwrap_or("INSUFFICIENT_DATA")
1895            .to_string();
1896        let now = Utc::now();
1897        let mut st = self.state.write();
1898        let acc = st.account_mut(account);
1899        let key = resource_key(&rt, &rid);
1900        acc.evaluations
1901            .entry(rule_name.clone())
1902            .or_default()
1903            .insert(
1904                key,
1905                EvaluationResult {
1906                    resource_type: rt,
1907                    resource_id: rid,
1908                    rule_name,
1909                    compliance_type: ct,
1910                    annotation: e
1911                        .get("Annotation")
1912                        .and_then(Value::as_str)
1913                        .map(String::from),
1914                    result_recorded_time: now,
1915                    config_rule_invoked_time: now,
1916                    ordering_timestamp: now,
1917                },
1918            );
1919        Ok(AwsResponse::ok_json(json!({})))
1920    }
1921
1922    fn delete_evaluation_results(
1923        &self,
1924        account: &str,
1925        body: &Value,
1926    ) -> Result<AwsResponse, AwsServiceError> {
1927        let name = require_str(body, "ConfigRuleName")?;
1928        let mut st = self.state.write();
1929        st.account_mut(account).evaluations.remove(&name);
1930        Ok(AwsResponse::ok_json(json!({})))
1931    }
1932
1933    fn get_custom_rule_policy(
1934        &self,
1935        account: &str,
1936        body: &Value,
1937    ) -> Result<AwsResponse, AwsServiceError> {
1938        let name = require_str(body, "ConfigRuleName")?;
1939        let st = self.state.read();
1940        let policy = st
1941            .account(account)
1942            .and_then(|a| a.rules.get(&name))
1943            .and_then(|r| {
1944                r.source
1945                    .pointer("/CustomPolicyDetails/PolicyText")
1946                    .and_then(Value::as_str)
1947                    .map(String::from)
1948            });
1949        Ok(AwsResponse::ok_json(json!({ "PolicyText": policy })))
1950    }
1951}
1952
1953// ─── Compliance ─────────────────────────────────────────────────────────────
1954
1955/// Fold a set of per-resource compliance types into a single rule/resource
1956/// verdict: NON_COMPLIANT if any is, else COMPLIANT if any is, else
1957/// INSUFFICIENT_DATA.
1958fn fold_compliance<'a>(types: impl Iterator<Item = &'a str>) -> String {
1959    let mut any_compliant = false;
1960    let mut any_noncompliant = false;
1961    for t in types {
1962        match t {
1963            "NON_COMPLIANT" => any_noncompliant = true,
1964            "COMPLIANT" => any_compliant = true,
1965            _ => {}
1966        }
1967    }
1968    if any_noncompliant {
1969        "NON_COMPLIANT".into()
1970    } else if any_compliant {
1971        "COMPLIANT".into()
1972    } else {
1973        "INSUFFICIENT_DATA".into()
1974    }
1975}
1976
1977impl ConfigService {
1978    fn describe_compliance_by_config_rule(
1979        &self,
1980        account: &str,
1981        body: &Value,
1982    ) -> Result<AwsResponse, AwsServiceError> {
1983        let names = string_list(body, "ConfigRuleNames");
1984        let filter = string_list(body, "ComplianceTypes");
1985        let st = self.state.read();
1986        let mut list = Vec::new();
1987        if let Some(acc) = st.account(account) {
1988            for rule in acc.rules.values() {
1989                if !names.is_empty() && !names.contains(&rule.name) {
1990                    continue;
1991                }
1992                let results = acc.evaluations.get(&rule.name);
1993                let compliance = results
1994                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
1995                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
1996                if !filter.is_empty() && !filter.contains(&compliance) {
1997                    continue;
1998                }
1999                list.push(json!({
2000                    "ConfigRuleName": rule.name,
2001                    "Compliance": { "ComplianceType": compliance },
2002                }));
2003            }
2004        }
2005        Ok(paged_response(
2006            "ComplianceByConfigRules",
2007            list,
2008            body,
2009            "NextToken",
2010        ))
2011    }
2012
2013    fn describe_compliance_by_resource(
2014        &self,
2015        account: &str,
2016        body: &Value,
2017    ) -> Result<AwsResponse, AwsServiceError> {
2018        let rtype = body.get("ResourceType").and_then(Value::as_str);
2019        let rid = body.get("ResourceId").and_then(Value::as_str);
2020        let filter = string_list(body, "ComplianceTypes");
2021        let st = self.state.read();
2022        // Aggregate per resource across all rules.
2023        let mut per_resource: std::collections::BTreeMap<(String, String), Vec<String>> =
2024            std::collections::BTreeMap::new();
2025        if let Some(acc) = st.account(account) {
2026            for results in acc.evaluations.values() {
2027                for r in results.values() {
2028                    if r.resource_type.is_empty() {
2029                        continue;
2030                    }
2031                    if let Some(t) = rtype {
2032                        if r.resource_type != t {
2033                            continue;
2034                        }
2035                    }
2036                    if let Some(i) = rid {
2037                        if r.resource_id != i {
2038                            continue;
2039                        }
2040                    }
2041                    per_resource
2042                        .entry((r.resource_type.clone(), r.resource_id.clone()))
2043                        .or_default()
2044                        .push(r.compliance_type.clone());
2045                }
2046            }
2047        }
2048        let mut list = Vec::new();
2049        for ((rt, ri), types) in per_resource {
2050            let compliance = fold_compliance(types.iter().map(String::as_str));
2051            if !filter.is_empty() && !filter.contains(&compliance) {
2052                continue;
2053            }
2054            list.push(json!({
2055                "ResourceType": rt,
2056                "ResourceId": ri,
2057                "Compliance": { "ComplianceType": compliance },
2058            }));
2059        }
2060        Ok(paged_response(
2061            "ComplianceByResources",
2062            list,
2063            body,
2064            "NextToken",
2065        ))
2066    }
2067
2068    fn get_compliance_details_by_config_rule(
2069        &self,
2070        account: &str,
2071        body: &Value,
2072    ) -> Result<AwsResponse, AwsServiceError> {
2073        let name = require_str(body, "ConfigRuleName")?;
2074        let filter = string_list(body, "ComplianceTypes");
2075        let st = self.state.read();
2076        let results: Vec<Value> = st
2077            .account(account)
2078            .and_then(|a| a.evaluations.get(&name))
2079            .map(|m| {
2080                m.values()
2081                    .filter(|r| filter.is_empty() || filter.contains(&r.compliance_type))
2082                    .map(|r| Self::evaluation_result_json(r, &name))
2083                    .collect()
2084            })
2085            .unwrap_or_default();
2086        Ok(paged_response(
2087            "EvaluationResults",
2088            results,
2089            body,
2090            "NextToken",
2091        ))
2092    }
2093
2094    fn get_compliance_details_by_resource(
2095        &self,
2096        account: &str,
2097        body: &Value,
2098    ) -> Result<AwsResponse, AwsServiceError> {
2099        let rtype = body
2100            .get("ResourceType")
2101            .and_then(Value::as_str)
2102            .unwrap_or_default()
2103            .to_string();
2104        let rid = body
2105            .get("ResourceId")
2106            .and_then(Value::as_str)
2107            .unwrap_or_default()
2108            .to_string();
2109        let filter = string_list(body, "ComplianceTypes");
2110        let st = self.state.read();
2111        let mut results = Vec::new();
2112        if let Some(acc) = st.account(account) {
2113            for (rule_name, m) in &acc.evaluations {
2114                for r in m.values() {
2115                    if r.resource_type == rtype
2116                        && r.resource_id == rid
2117                        && (filter.is_empty() || filter.contains(&r.compliance_type))
2118                    {
2119                        results.push(Self::evaluation_result_json(r, rule_name));
2120                    }
2121                }
2122            }
2123        }
2124        Ok(paged_response(
2125            "EvaluationResults",
2126            results,
2127            body,
2128            "NextToken",
2129        ))
2130    }
2131
2132    fn evaluation_result_json(r: &EvaluationResult, rule_name: &str) -> Value {
2133        json!({
2134            "EvaluationResultIdentifier": {
2135                "EvaluationResultQualifier": {
2136                    "ConfigRuleName": rule_name,
2137                    "ResourceType": r.resource_type,
2138                    "ResourceId": r.resource_id,
2139                },
2140                "OrderingTimestamp": r.ordering_timestamp.timestamp() as f64,
2141            },
2142            "ComplianceType": r.compliance_type,
2143            "ResultRecordedTime": r.result_recorded_time.timestamp() as f64,
2144            "ConfigRuleInvokedTime": r.config_rule_invoked_time.timestamp() as f64,
2145            "Annotation": r.annotation,
2146        })
2147    }
2148
2149    fn get_compliance_summary_by_config_rule(
2150        &self,
2151        account: &str,
2152    ) -> Result<AwsResponse, AwsServiceError> {
2153        let st = self.state.read();
2154        let mut compliant = 0;
2155        let mut noncompliant = 0;
2156        if let Some(acc) = st.account(account) {
2157            for rule in acc.rules.values() {
2158                let c = acc
2159                    .evaluations
2160                    .get(&rule.name)
2161                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
2162                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
2163                match c.as_str() {
2164                    "COMPLIANT" => compliant += 1,
2165                    "NON_COMPLIANT" => noncompliant += 1,
2166                    _ => {}
2167                }
2168            }
2169        }
2170        Ok(AwsResponse::ok_json(json!({
2171            "ComplianceSummary": {
2172                "CompliantResourceCount": { "CappedCount": compliant, "CapExceeded": false },
2173                "NonCompliantResourceCount": { "CappedCount": noncompliant, "CapExceeded": false },
2174                "ComplianceSummaryTimestamp": Utc::now().timestamp() as f64,
2175            }
2176        })))
2177    }
2178
2179    fn get_compliance_summary_by_resource_type(
2180        &self,
2181        account: &str,
2182        body: &Value,
2183    ) -> Result<AwsResponse, AwsServiceError> {
2184        let types = string_list(body, "ResourceTypes");
2185        let st = self.state.read();
2186        // Collect the folded compliance per resource, grouped by resource type.
2187        // A resource type is COMPLIANT/NON_COMPLIANT once per distinct resource
2188        // (folded across every rule that evaluated it).
2189        let mut per_type_resources: std::collections::BTreeMap<
2190            String,
2191            std::collections::BTreeMap<String, Vec<String>>,
2192        > = std::collections::BTreeMap::new();
2193        if let Some(acc) = st.account(account) {
2194            for m in acc.evaluations.values() {
2195                for r in m.values() {
2196                    if r.resource_type.is_empty() {
2197                        continue;
2198                    }
2199                    if !types.is_empty() && !types.contains(&r.resource_type) {
2200                        continue;
2201                    }
2202                    per_type_resources
2203                        .entry(r.resource_type.clone())
2204                        .or_default()
2205                        .entry(r.resource_id.clone())
2206                        .or_default()
2207                        .push(r.compliance_type.clone());
2208                }
2209            }
2210        }
2211        let now = Utc::now().timestamp() as f64;
2212        let summary_json = |resource_type: &str,
2213                            resources: &std::collections::BTreeMap<String, Vec<String>>|
2214         -> Value {
2215            let mut compliant = 0;
2216            let mut noncompliant = 0;
2217            for c in resources.values() {
2218                match fold_compliance(c.iter().map(String::as_str)).as_str() {
2219                    "COMPLIANT" => compliant += 1,
2220                    "NON_COMPLIANT" => noncompliant += 1,
2221                    _ => {}
2222                }
2223            }
2224            let summary = json!({
2225                "CompliantResourceCount": { "CappedCount": compliant, "CapExceeded": false },
2226                "NonCompliantResourceCount": { "CappedCount": noncompliant, "CapExceeded": false },
2227                "ComplianceSummaryTimestamp": now,
2228            });
2229            if resource_type.is_empty() {
2230                json!({ "ComplianceSummary": summary })
2231            } else {
2232                json!({ "ResourceType": resource_type, "ComplianceSummary": summary })
2233            }
2234        };
2235        let list: Vec<Value> = if types.is_empty() {
2236            // No filter: one aggregated summary over all resource types, with no
2237            // ResourceType label (matches AWS).
2238            let mut all: std::collections::BTreeMap<String, Vec<String>> =
2239                std::collections::BTreeMap::new();
2240            for resources in per_type_resources.values() {
2241                for (rid, c) in resources {
2242                    all.entry(rid.clone())
2243                        .or_default()
2244                        .extend(c.iter().cloned());
2245                }
2246            }
2247            vec![summary_json("", &all)]
2248        } else {
2249            // One summary per requested resource type (empty when the type has
2250            // no evaluated resources).
2251            types
2252                .iter()
2253                .map(|t| {
2254                    let empty = std::collections::BTreeMap::new();
2255                    let resources = per_type_resources.get(t).unwrap_or(&empty);
2256                    summary_json(t, resources)
2257                })
2258                .collect()
2259        };
2260        Ok(AwsResponse::ok_json(json!({
2261            "ComplianceSummariesByResourceType": list
2262        })))
2263    }
2264}
2265
2266// ─── Remediation ────────────────────────────────────────────────────────────
2267
2268impl ConfigService {
2269    fn put_remediation_configurations(
2270        &self,
2271        account: &str,
2272        region: &str,
2273        body: &Value,
2274    ) -> Result<AwsResponse, AwsServiceError> {
2275        let configs = body
2276            .get("RemediationConfigurations")
2277            .and_then(Value::as_array)
2278            .cloned()
2279            .unwrap_or_default();
2280        let mut failed = Vec::new();
2281        let mut st = self.state.write();
2282        let acc = st.account_mut(account);
2283        for c in &configs {
2284            let rule_name = match c.get("ConfigRuleName").and_then(Value::as_str) {
2285                Some(n) => n.to_string(),
2286                None => {
2287                    failed.push(json!({ "FailureMessage": "ConfigRuleName is required", "FailedItems": [c] }));
2288                    continue;
2289                }
2290            };
2291            acc.remediation_configs.insert(
2292                rule_name.clone(),
2293                RemediationConfiguration {
2294                    config_rule_name: rule_name.clone(),
2295                    arn: format!("arn:aws:config:{region}:{account}:remediation-configuration/{rule_name}/{}", short_id()),
2296                    target_type: c.get("TargetType").and_then(Value::as_str).unwrap_or("SSM_DOCUMENT").to_string(),
2297                    target_id: c.get("TargetId").and_then(Value::as_str).unwrap_or_default().to_string(),
2298                    target_version: c.get("TargetVersion").and_then(Value::as_str).map(String::from),
2299                    parameters: c.get("Parameters").cloned(),
2300                    resource_type: c.get("ResourceType").and_then(Value::as_str).map(String::from),
2301                    automatic: c.get("Automatic").and_then(Value::as_bool).unwrap_or(false),
2302                    execution_controls: c.get("ExecutionControls").cloned(),
2303                    maximum_automatic_attempts: c.get("MaximumAutomaticAttempts").and_then(Value::as_i64),
2304                    retry_attempt_seconds: c.get("RetryAttemptSeconds").and_then(Value::as_i64),
2305                    created_by_service: None,
2306                },
2307            );
2308        }
2309        Ok(AwsResponse::ok_json(json!({ "FailedBatches": failed })))
2310    }
2311
2312    fn remediation_json(r: &RemediationConfiguration) -> Value {
2313        let mut v = json!({
2314            "ConfigRuleName": r.config_rule_name,
2315            "TargetType": r.target_type,
2316            "TargetId": r.target_id,
2317            "Arn": r.arn,
2318            "Automatic": r.automatic,
2319        });
2320        if let Some(t) = &r.target_version {
2321            v["TargetVersion"] = json!(t);
2322        }
2323        if let Some(p) = &r.parameters {
2324            v["Parameters"] = p.clone();
2325        }
2326        if let Some(rt) = &r.resource_type {
2327            v["ResourceType"] = json!(rt);
2328        }
2329        if let Some(e) = &r.execution_controls {
2330            v["ExecutionControls"] = e.clone();
2331        }
2332        if let Some(m) = r.maximum_automatic_attempts {
2333            v["MaximumAutomaticAttempts"] = json!(m);
2334        }
2335        if let Some(s) = r.retry_attempt_seconds {
2336            v["RetryAttemptSeconds"] = json!(s);
2337        }
2338        v
2339    }
2340
2341    fn describe_remediation_configurations(
2342        &self,
2343        account: &str,
2344        body: &Value,
2345    ) -> Result<AwsResponse, AwsServiceError> {
2346        let names = string_list(body, "ConfigRuleNames");
2347        let st = self.state.read();
2348        let list: Vec<Value> = st
2349            .account(account)
2350            .map(|a| {
2351                a.remediation_configs
2352                    .values()
2353                    .filter(|r| names.is_empty() || names.contains(&r.config_rule_name))
2354                    .map(Self::remediation_json)
2355                    .collect()
2356            })
2357            .unwrap_or_default();
2358        Ok(AwsResponse::ok_json(
2359            json!({ "RemediationConfigurations": list }),
2360        ))
2361    }
2362
2363    fn delete_remediation_configuration(
2364        &self,
2365        account: &str,
2366        body: &Value,
2367    ) -> Result<AwsResponse, AwsServiceError> {
2368        let name = require_str(body, "ConfigRuleName")?;
2369        let mut st = self.state.write();
2370        let acc = st.account_mut(account);
2371        if acc.remediation_configs.remove(&name).is_none() {
2372            return Err(no_such(
2373                "NoSuchRemediationConfigurationException",
2374                "No RemediationConfiguration for rule exists.",
2375            ));
2376        }
2377        acc.remediation_executions
2378            .retain(|_, e| e.config_rule_name != name);
2379        Ok(AwsResponse::ok_json(json!({})))
2380    }
2381
2382    fn put_remediation_exceptions(
2383        &self,
2384        account: &str,
2385        body: &Value,
2386    ) -> Result<AwsResponse, AwsServiceError> {
2387        let rule_name = require_str(body, "ConfigRuleName")?;
2388        let keys = body
2389            .get("ResourceKeys")
2390            .and_then(Value::as_array)
2391            .cloned()
2392            .unwrap_or_default();
2393        let message = body
2394            .get("Message")
2395            .and_then(Value::as_str)
2396            .map(String::from);
2397        // ExpirationTime is a request-level field applied to every supplied key.
2398        let expiration_time = body
2399            .get("ExpirationTime")
2400            .and_then(Value::as_f64)
2401            .and_then(|s| DateTime::from_timestamp(s as i64, 0));
2402        let mut st = self.state.write();
2403        let acc = st.account_mut(account);
2404        for k in &keys {
2405            let rt = k
2406                .get("ResourceType")
2407                .and_then(Value::as_str)
2408                .unwrap_or_default()
2409                .to_string();
2410            let rid = k
2411                .get("ResourceId")
2412                .and_then(Value::as_str)
2413                .unwrap_or_default()
2414                .to_string();
2415            let key = format!("{rule_name}\u{1}{rt}\u{1}{rid}");
2416            acc.remediation_exceptions.insert(
2417                key,
2418                RemediationException {
2419                    config_rule_name: rule_name.clone(),
2420                    resource_type: rt,
2421                    resource_id: rid,
2422                    message: message.clone(),
2423                    expiration_time,
2424                },
2425            );
2426        }
2427        Ok(AwsResponse::ok_json(json!({ "FailedBatches": [] })))
2428    }
2429
2430    fn describe_remediation_exceptions(
2431        &self,
2432        account: &str,
2433        body: &Value,
2434    ) -> Result<AwsResponse, AwsServiceError> {
2435        let rule_name = require_str(body, "ConfigRuleName")?;
2436        let st = self.state.read();
2437        let list: Vec<Value> = st
2438            .account(account)
2439            .map(|a| {
2440                a.remediation_exceptions
2441                    .values()
2442                    .filter(|e| e.config_rule_name == rule_name)
2443                    .map(|e| {
2444                        let mut v = json!({
2445                            "ConfigRuleName": e.config_rule_name,
2446                            "ResourceType": e.resource_type,
2447                            "ResourceId": e.resource_id,
2448                            "Message": e.message,
2449                        });
2450                        if let Some(exp) = e.expiration_time {
2451                            v["ExpirationTime"] = json!(exp.timestamp() as f64);
2452                        }
2453                        v
2454                    })
2455                    .collect()
2456            })
2457            .unwrap_or_default();
2458        Ok(paged_response(
2459            "RemediationExceptions",
2460            list,
2461            body,
2462            "NextToken",
2463        ))
2464    }
2465
2466    fn delete_remediation_exceptions(
2467        &self,
2468        account: &str,
2469        body: &Value,
2470    ) -> Result<AwsResponse, AwsServiceError> {
2471        let rule_name = require_str(body, "ConfigRuleName")?;
2472        let keys = body
2473            .get("ResourceKeys")
2474            .and_then(Value::as_array)
2475            .cloned()
2476            .unwrap_or_default();
2477        let mut st = self.state.write();
2478        let acc = st.account_mut(account);
2479        for k in &keys {
2480            let rt = k
2481                .get("ResourceType")
2482                .and_then(Value::as_str)
2483                .unwrap_or_default();
2484            let rid = k
2485                .get("ResourceId")
2486                .and_then(Value::as_str)
2487                .unwrap_or_default();
2488            acc.remediation_exceptions
2489                .remove(&format!("{rule_name}\u{1}{rt}\u{1}{rid}"));
2490        }
2491        Ok(AwsResponse::ok_json(json!({ "FailedBatches": [] })))
2492    }
2493
2494    fn start_remediation_execution(
2495        &self,
2496        account: &str,
2497        body: &Value,
2498    ) -> Result<AwsResponse, AwsServiceError> {
2499        let rule_name = require_str(body, "ConfigRuleName")?;
2500        let keys = body
2501            .get("ResourceKeys")
2502            .and_then(Value::as_array)
2503            .cloned()
2504            .unwrap_or_default();
2505        let now = Utc::now();
2506        let mut st = self.state.write();
2507        let acc = st.account_mut(account);
2508        if !acc.remediation_configs.contains_key(&rule_name) {
2509            return Err(no_such(
2510                "NoSuchRemediationConfigurationException",
2511                "No RemediationConfiguration for rule exists.",
2512            ));
2513        }
2514        // Record an execution per resource key. The SSM Automation the target
2515        // document would drive is not simulated here, so the execution is
2516        // recorded as QUEUED (accepted, outcome unknown) rather than a
2517        // fabricated SUCCEEDED.
2518        let mut failed = Vec::new();
2519        for k in &keys {
2520            let (Some(rt), Some(rid)) = (
2521                k.get("resourceType")
2522                    .or_else(|| k.get("ResourceType"))
2523                    .and_then(Value::as_str),
2524                k.get("resourceId")
2525                    .or_else(|| k.get("ResourceId"))
2526                    .and_then(Value::as_str),
2527            ) else {
2528                failed.push(k.clone());
2529                continue;
2530            };
2531            let key = format!("{rule_name}\u{1}{rt}\u{1}{rid}");
2532            acc.remediation_executions.insert(
2533                key,
2534                RemediationExecutionStatus {
2535                    config_rule_name: rule_name.clone(),
2536                    resource_type: rt.to_string(),
2537                    resource_id: rid.to_string(),
2538                    state: "QUEUED".into(),
2539                    invocation_time: now,
2540                    last_updated_time: now,
2541                },
2542            );
2543        }
2544        Ok(AwsResponse::ok_json(json!({ "FailedItems": failed })))
2545    }
2546
2547    fn describe_remediation_execution_status(
2548        &self,
2549        account: &str,
2550        body: &Value,
2551    ) -> Result<AwsResponse, AwsServiceError> {
2552        let rule_name = require_str(body, "ConfigRuleName")?;
2553        let keys = body
2554            .get("ResourceKeys")
2555            .and_then(Value::as_array)
2556            .cloned()
2557            .unwrap_or_default();
2558        // Restrict to the requested resource keys, when any were supplied.
2559        let wanted: Vec<(String, String)> = keys
2560            .iter()
2561            .filter_map(|k| {
2562                let rt = k
2563                    .get("resourceType")
2564                    .or_else(|| k.get("ResourceType"))
2565                    .and_then(Value::as_str)?;
2566                let rid = k
2567                    .get("resourceId")
2568                    .or_else(|| k.get("ResourceId"))
2569                    .and_then(Value::as_str)?;
2570                Some((rt.to_string(), rid.to_string()))
2571            })
2572            .collect();
2573        let st = self.state.read();
2574        let list: Vec<Value> = st
2575            .account(account)
2576            .map(|a| {
2577                a.remediation_executions
2578                    .values()
2579                    .filter(|e| e.config_rule_name == rule_name)
2580                    .filter(|e| {
2581                        wanted.is_empty()
2582                            || wanted
2583                                .iter()
2584                                .any(|(rt, rid)| *rt == e.resource_type && *rid == e.resource_id)
2585                    })
2586                    .map(|e| {
2587                        let inv = e.invocation_time.timestamp() as f64;
2588                        let upd = e.last_updated_time.timestamp() as f64;
2589                        json!({
2590                            "ResourceKey": { "resourceType": e.resource_type, "resourceId": e.resource_id },
2591                            "State": e.state,
2592                            "StepDetails": [{ "Name": "remediate", "State": e.state, "StartTime": inv }],
2593                            "InvocationTime": inv,
2594                            "LastUpdatedTime": upd,
2595                        })
2596                    })
2597                    .collect()
2598            })
2599            .unwrap_or_default();
2600        Ok(paged_response(
2601            "RemediationExecutionStatuses",
2602            list,
2603            body,
2604            "NextToken",
2605        ))
2606    }
2607}
2608
2609// ─── Conformance packs ──────────────────────────────────────────────────────
2610
2611/// Extract the `ConfigRuleName`s declared by a conformance-pack template body.
2612/// Used to scope conformance-pack compliance to the pack's own rules. Both JSON
2613/// and YAML CloudFormation templates are parsed (conformance-pack templates are
2614/// most commonly authored in YAML); a template with no parseable rule names
2615/// yields an empty list (compliance then aggregates over all account rules).
2616fn extract_pack_rule_names(template: Option<&str>) -> Vec<String> {
2617    let Some(t) = template else { return Vec::new() };
2618    // Try JSON first, then YAML (a superset that also accepts JSON, but JSON is
2619    // cheaper and the common case for programmatically-generated templates).
2620    let v: Value = serde_json::from_str(t)
2621        .or_else(|_| serde_yaml::from_str::<Value>(t))
2622        .unwrap_or(Value::Null);
2623    let Some(resources) = v.get("Resources").and_then(Value::as_object) else {
2624        return Vec::new();
2625    };
2626    resources
2627        .values()
2628        .filter(|r| r.get("Type").and_then(Value::as_str) == Some("AWS::Config::ConfigRule"))
2629        .filter_map(|r| {
2630            r.pointer("/Properties/ConfigRuleName")
2631                .and_then(Value::as_str)
2632                .map(String::from)
2633        })
2634        .collect()
2635}
2636
2637impl ConfigService {
2638    fn put_conformance_pack(
2639        &self,
2640        account: &str,
2641        region: &str,
2642        body: &Value,
2643    ) -> Result<AwsResponse, AwsServiceError> {
2644        let name = require_str(body, "ConformancePackName")?;
2645        let template_body = body
2646            .get("TemplateBody")
2647            .and_then(Value::as_str)
2648            .map(String::from);
2649        let template_s3_uri = body
2650            .get("TemplateS3Uri")
2651            .and_then(Value::as_str)
2652            .map(String::from);
2653        if template_body.is_none()
2654            && template_s3_uri.is_none()
2655            && body.get("TemplateSSMDocumentDetails").is_none()
2656        {
2657            return Err(invalid(
2658                "Either TemplateBody, TemplateS3Uri, or TemplateSSMDocumentDetails is required",
2659            ));
2660        }
2661        let now = Utc::now();
2662        let arn = format!(
2663            "arn:aws:config:{region}:{account}:conformance-pack/{name}-{}",
2664            short_id()
2665        );
2666        let id = format!("conformance-pack-{}", short_id());
2667        let rule_names = extract_pack_rule_names(template_body.as_deref());
2668        let mut st = self.state.write();
2669        let acc = st.account_mut(account);
2670        let existing = acc.conformance_packs.get(&name);
2671        let pack = ConformancePack {
2672            name: name.clone(),
2673            arn: existing.map(|p| p.arn.clone()).unwrap_or(arn.clone()),
2674            id: existing.map(|p| p.id.clone()).unwrap_or(id),
2675            delivery_s3_bucket: body
2676                .get("DeliveryS3Bucket")
2677                .and_then(Value::as_str)
2678                .map(String::from),
2679            delivery_s3_key_prefix: body
2680                .get("DeliveryS3KeyPrefix")
2681                .and_then(Value::as_str)
2682                .map(String::from),
2683            input_parameters: body
2684                .get("ConformancePackInputParameters")
2685                .and_then(Value::as_array)
2686                .cloned()
2687                .unwrap_or_default(),
2688            template_body,
2689            template_s3_uri,
2690            template_ssm_document_details: body.get("TemplateSSMDocumentDetails").cloned(),
2691            last_update_requested_time: now,
2692            created_by: None,
2693            rule_names,
2694        };
2695        let out_arn = pack.arn.clone();
2696        acc.conformance_packs.insert(name, pack);
2697        let tags = parse_create_tags(body);
2698        if !tags.is_empty() {
2699            let entry = acc.tags.entry(out_arn.clone()).or_default();
2700            for (k, v) in tags {
2701                entry.insert(k, v);
2702            }
2703        }
2704        Ok(AwsResponse::ok_json(
2705            json!({ "ConformancePackArn": out_arn }),
2706        ))
2707    }
2708
2709    fn describe_conformance_packs(
2710        &self,
2711        account: &str,
2712        body: &Value,
2713    ) -> Result<AwsResponse, AwsServiceError> {
2714        let names = string_list(body, "ConformancePackNames");
2715        let st = self.state.read();
2716        let list: Vec<Value> = st
2717            .account(account)
2718            .map(|a| {
2719                a.conformance_packs
2720                    .values()
2721                    .filter(|p| names.is_empty() || names.contains(&p.name))
2722                    .map(|p| {
2723                        json!({
2724                            "ConformancePackName": p.name,
2725                            "ConformancePackArn": p.arn,
2726                            "ConformancePackId": p.id,
2727                            "DeliveryS3Bucket": p.delivery_s3_bucket,
2728                            "DeliveryS3KeyPrefix": p.delivery_s3_key_prefix,
2729                            "ConformancePackInputParameters": p.input_parameters,
2730                            "LastUpdateRequestedTime": p.last_update_requested_time.timestamp() as f64,
2731                            "TemplateSSMDocumentDetails": p.template_ssm_document_details,
2732                        })
2733                    })
2734                    .collect()
2735            })
2736            .unwrap_or_default();
2737        if !names.is_empty() {
2738            for n in &names {
2739                if !st
2740                    .account(account)
2741                    .map(|a| a.conformance_packs.contains_key(n))
2742                    .unwrap_or(false)
2743                {
2744                    return Err(no_such(
2745                        "NoSuchConformancePackException",
2746                        format!("Cannot find conformance pack with name '{n}'."),
2747                    ));
2748                }
2749            }
2750        }
2751        Ok(paged_response(
2752            "ConformancePackDetails",
2753            list,
2754            body,
2755            "NextToken",
2756        ))
2757    }
2758
2759    fn delete_conformance_pack(
2760        &self,
2761        account: &str,
2762        body: &Value,
2763    ) -> Result<AwsResponse, AwsServiceError> {
2764        let name = require_str(body, "ConformancePackName")?;
2765        let mut st = self.state.write();
2766        let acc = st.account_mut(account);
2767        if acc.conformance_packs.remove(&name).is_none() {
2768            return Err(no_such(
2769                "NoSuchConformancePackException",
2770                format!("Cannot find conformance pack with name '{name}'."),
2771            ));
2772        }
2773        Ok(AwsResponse::ok_json(json!({})))
2774    }
2775
2776    fn describe_conformance_pack_status(
2777        &self,
2778        account: &str,
2779        body: &Value,
2780    ) -> Result<AwsResponse, AwsServiceError> {
2781        let names = string_list(body, "ConformancePackNames");
2782        let now = Utc::now().timestamp() as f64;
2783        let st = self.state.read();
2784        let list: Vec<Value> = st
2785            .account(account)
2786            .map(|a| {
2787                a.conformance_packs
2788                    .values()
2789                    .filter(|p| names.is_empty() || names.contains(&p.name))
2790                    .map(|p| {
2791                        json!({
2792                            "ConformancePackName": p.name,
2793                            "ConformancePackId": p.id,
2794                            "ConformancePackArn": p.arn,
2795                            "ConformancePackState": "CREATE_COMPLETE",
2796                            "StackArn": format!("arn:aws:cloudformation:us-east-1:{account}:stack/awsconfigconforms-{}/{}", p.name, short_id()),
2797                            "LastUpdateRequestedTime": now,
2798                            "LastUpdateCompletedTime": now,
2799                        })
2800                    })
2801                    .collect()
2802            })
2803            .unwrap_or_default();
2804        Ok(paged_response(
2805            "ConformancePackStatusDetails",
2806            list,
2807            body,
2808            "NextToken",
2809        ))
2810    }
2811
2812    /// Rule names that belong to a pack (its own declared rules, or all account
2813    /// rules when the template did not declare any parseable ones).
2814    fn pack_rule_names(acc: &AccountState, pack: &ConformancePack) -> Vec<String> {
2815        if pack.rule_names.is_empty() {
2816            acc.rules.keys().cloned().collect()
2817        } else {
2818            pack.rule_names
2819                .iter()
2820                .filter(|n| acc.rules.contains_key(*n))
2821                .cloned()
2822                .collect()
2823        }
2824    }
2825
2826    fn describe_conformance_pack_compliance(
2827        &self,
2828        account: &str,
2829        body: &Value,
2830    ) -> Result<AwsResponse, AwsServiceError> {
2831        let name = require_str(body, "ConformancePackName")?;
2832        let st = self.state.read();
2833        let acc = st.account(account).ok_or_else(|| {
2834            no_such(
2835                "NoSuchConformancePackException",
2836                "No such conformance pack.",
2837            )
2838        })?;
2839        let pack = acc.conformance_packs.get(&name).ok_or_else(|| {
2840            no_such(
2841                "NoSuchConformancePackException",
2842                format!("Cannot find conformance pack with name '{name}'."),
2843            )
2844        })?;
2845        let list: Vec<Value> = Self::pack_rule_names(acc, pack)
2846            .iter()
2847            .map(|rule| {
2848                let c = acc
2849                    .evaluations
2850                    .get(rule)
2851                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
2852                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
2853                json!({ "ConfigRuleName": rule, "ComplianceType": c, "Controls": [] })
2854            })
2855            .collect();
2856        let (page, next) = paginate(list, body);
2857        let mut out =
2858            json!({ "ConformancePackName": name, "ConformancePackRuleComplianceList": page });
2859        if let Some(t) = next {
2860            out["NextToken"] = json!(t);
2861        }
2862        Ok(AwsResponse::ok_json(out))
2863    }
2864
2865    fn get_conformance_pack_compliance_summary(
2866        &self,
2867        account: &str,
2868        body: &Value,
2869    ) -> Result<AwsResponse, AwsServiceError> {
2870        let names = string_list(body, "ConformancePackNames");
2871        let st = self.state.read();
2872        let acc = st.account(account);
2873        let list: Vec<Value> = acc
2874            .map(|a| {
2875                a.conformance_packs
2876                    .values()
2877                    .filter(|p| names.is_empty() || names.contains(&p.name))
2878                    .map(|p| {
2879                        let rules = Self::pack_rule_names(a, p);
2880                        let noncompliant = rules.iter().any(|r| {
2881                            a.evaluations.get(r).map(|m| m.values().any(|e| e.compliance_type == "NON_COMPLIANT")).unwrap_or(false)
2882                        });
2883                        json!({
2884                            "ConformancePackName": p.name,
2885                            "ConformancePackComplianceStatus": if noncompliant { "NON_COMPLIANT" } else { "COMPLIANT" },
2886                        })
2887                    })
2888                    .collect()
2889            })
2890            .unwrap_or_default();
2891        Ok(AwsResponse::ok_json(
2892            json!({ "ConformancePackComplianceSummaryList": list }),
2893        ))
2894    }
2895
2896    fn get_conformance_pack_compliance_details(
2897        &self,
2898        account: &str,
2899        body: &Value,
2900    ) -> Result<AwsResponse, AwsServiceError> {
2901        let name = require_str(body, "ConformancePackName")?;
2902        let st = self.state.read();
2903        let acc = st.account(account).ok_or_else(|| {
2904            no_such(
2905                "NoSuchConformancePackException",
2906                "No such conformance pack.",
2907            )
2908        })?;
2909        let pack = acc.conformance_packs.get(&name).ok_or_else(|| {
2910            no_such(
2911                "NoSuchConformancePackException",
2912                format!("Cannot find conformance pack with name '{name}'."),
2913            )
2914        })?;
2915        let mut results = Vec::new();
2916        for rule in Self::pack_rule_names(acc, pack) {
2917            if let Some(m) = acc.evaluations.get(&rule) {
2918                for r in m.values() {
2919                    results.push(json!({
2920                        "ConfigRuleInvokedTime": r.config_rule_invoked_time.timestamp() as f64,
2921                        "ResultRecordedTime": r.result_recorded_time.timestamp() as f64,
2922                        "ComplianceType": r.compliance_type,
2923                        "EvaluationResultIdentifier": {
2924                            "EvaluationResultQualifier": {
2925                                "ConfigRuleName": rule,
2926                                "ResourceType": r.resource_type,
2927                                "ResourceId": r.resource_id,
2928                            },
2929                            "OrderingTimestamp": r.ordering_timestamp.timestamp() as f64,
2930                        },
2931                    }));
2932                }
2933            }
2934        }
2935        let (page, next) = paginate(results, body);
2936        let mut out =
2937            json!({ "ConformancePackName": name, "ConformancePackRuleEvaluationResults": page });
2938        if let Some(t) = next {
2939            out["NextToken"] = json!(t);
2940        }
2941        Ok(AwsResponse::ok_json(out))
2942    }
2943
2944    fn list_conformance_pack_compliance_scores(
2945        &self,
2946        account: &str,
2947    ) -> Result<AwsResponse, AwsServiceError> {
2948        let st = self.state.read();
2949        let list: Vec<Value> = st
2950            .account(account)
2951            .map(|a| {
2952                a.conformance_packs
2953                    .values()
2954                    .map(|p| {
2955                        let rules = Self::pack_rule_names(a, p);
2956                        let total = rules.len().max(1);
2957                        let compliant = rules
2958                            .iter()
2959                            .filter(|r| {
2960                                a.evaluations
2961                                    .get(*r)
2962                                    .map(|m| {
2963                                        !m.values().any(|e| e.compliance_type == "NON_COMPLIANT")
2964                                    })
2965                                    .unwrap_or(true)
2966                            })
2967                            .count();
2968                        let score = (compliant as f64 / total as f64) * 100.0;
2969                        json!({
2970                            "Score": format!("{score:.2}"),
2971                            "ConformancePackName": p.name,
2972                            "LastUpdatedTime": p.last_update_requested_time.timestamp() as f64,
2973                        })
2974                    })
2975                    .collect()
2976            })
2977            .unwrap_or_default();
2978        Ok(AwsResponse::ok_json(
2979            json!({ "ConformancePackComplianceScores": list }),
2980        ))
2981    }
2982}
2983
2984// ─── Organization rules / packs ─────────────────────────────────────────────
2985
2986impl ConfigService {
2987    fn put_organization_config_rule(
2988        &self,
2989        account: &str,
2990        region: &str,
2991        body: &Value,
2992    ) -> Result<AwsResponse, AwsServiceError> {
2993        let name = require_str(body, "OrganizationConfigRuleName")?;
2994        let arn = format!(
2995            "arn:aws:config:{region}:{account}:organization-config-rule/{name}-{}",
2996            short_id()
2997        );
2998        let mut st = self.state.write();
2999        let acc = st.account_mut(account);
3000        let out_arn = acc
3001            .org_rules
3002            .get(&name)
3003            .map(|r| r.arn.clone())
3004            .unwrap_or(arn);
3005        acc.org_rules.insert(
3006            name.clone(),
3007            OrganizationConfigRule {
3008                name,
3009                arn: out_arn.clone(),
3010                managed_rule_metadata: body.get("OrganizationManagedRuleMetadata").cloned(),
3011                custom_rule_metadata: body.get("OrganizationCustomRuleMetadata").cloned(),
3012                custom_policy_rule_metadata: body
3013                    .get("OrganizationCustomPolicyRuleMetadata")
3014                    .cloned(),
3015                excluded_accounts: string_list(body, "ExcludedAccounts"),
3016                last_update_time: Utc::now(),
3017            },
3018        );
3019        let tags = parse_create_tags(body);
3020        if !tags.is_empty() {
3021            let entry = acc.tags.entry(out_arn.clone()).or_default();
3022            for (k, v) in tags {
3023                entry.insert(k, v);
3024            }
3025        }
3026        Ok(AwsResponse::ok_json(
3027            json!({ "OrganizationConfigRuleArn": out_arn }),
3028        ))
3029    }
3030
3031    fn describe_organization_config_rules(
3032        &self,
3033        account: &str,
3034        body: &Value,
3035    ) -> Result<AwsResponse, AwsServiceError> {
3036        let names = string_list(body, "OrganizationConfigRuleNames");
3037        let st = self.state.read();
3038        let list: Vec<Value> = st
3039            .account(account)
3040            .map(|a| {
3041                a.org_rules
3042                    .values()
3043                    .filter(|r| names.is_empty() || names.contains(&r.name))
3044                    .map(|r| {
3045                        json!({
3046                            "OrganizationConfigRuleName": r.name,
3047                            "OrganizationConfigRuleArn": r.arn,
3048                            "OrganizationManagedRuleMetadata": r.managed_rule_metadata,
3049                            "OrganizationCustomRuleMetadata": r.custom_rule_metadata,
3050                            "OrganizationCustomPolicyRuleMetadata": r.custom_policy_rule_metadata,
3051                            "ExcludedAccounts": r.excluded_accounts,
3052                            "LastUpdateTime": r.last_update_time.timestamp() as f64,
3053                        })
3054                    })
3055                    .collect()
3056            })
3057            .unwrap_or_default();
3058        Ok(AwsResponse::ok_json(
3059            json!({ "OrganizationConfigRules": list }),
3060        ))
3061    }
3062
3063    fn delete_organization_config_rule(
3064        &self,
3065        account: &str,
3066        body: &Value,
3067    ) -> Result<AwsResponse, AwsServiceError> {
3068        let name = require_str(body, "OrganizationConfigRuleName")?;
3069        let mut st = self.state.write();
3070        let acc = st.account_mut(account);
3071        if acc.org_rules.remove(&name).is_none() {
3072            return Err(no_such(
3073                "NoSuchOrganizationConfigRuleException",
3074                format!("Cannot find organization config rule '{name}'."),
3075            ));
3076        }
3077        Ok(AwsResponse::ok_json(json!({})))
3078    }
3079
3080    fn describe_organization_config_rule_statuses(
3081        &self,
3082        account: &str,
3083        body: &Value,
3084    ) -> Result<AwsResponse, AwsServiceError> {
3085        let names = string_list(body, "OrganizationConfigRuleNames");
3086        let now = Utc::now().timestamp() as f64;
3087        let st = self.state.read();
3088        let list: Vec<Value> = st
3089            .account(account)
3090            .map(|a| {
3091                a.org_rules
3092                    .values()
3093                    .filter(|r| names.is_empty() || names.contains(&r.name))
3094                    .map(|r| {
3095                        json!({ "OrganizationConfigRuleName": r.name, "OrganizationRuleStatus": "CREATE_SUCCESSFUL", "LastUpdateTime": now })
3096                    })
3097                    .collect()
3098            })
3099            .unwrap_or_default();
3100        Ok(AwsResponse::ok_json(
3101            json!({ "OrganizationConfigRuleStatuses": list }),
3102        ))
3103    }
3104
3105    fn get_organization_config_rule_detailed_status(
3106        &self,
3107        account: &str,
3108        body: &Value,
3109    ) -> Result<AwsResponse, AwsServiceError> {
3110        let name = require_str(body, "OrganizationConfigRuleName")?;
3111        let st = self.state.read();
3112        if !st
3113            .account(account)
3114            .map(|a| a.org_rules.contains_key(&name))
3115            .unwrap_or(false)
3116        {
3117            return Err(no_such(
3118                "NoSuchOrganizationConfigRuleException",
3119                format!("Cannot find organization config rule '{name}'."),
3120            ));
3121        }
3122        Ok(AwsResponse::ok_json(
3123            json!({ "OrganizationConfigRuleDetailedStatus": [] }),
3124        ))
3125    }
3126
3127    fn get_organization_custom_rule_policy(
3128        &self,
3129        account: &str,
3130        body: &Value,
3131    ) -> Result<AwsResponse, AwsServiceError> {
3132        let name = require_str(body, "OrganizationConfigRuleName")?;
3133        let st = self.state.read();
3134        let policy = st
3135            .account(account)
3136            .and_then(|a| a.org_rules.get(&name))
3137            .and_then(|r| r.custom_policy_rule_metadata.as_ref())
3138            .and_then(|m| {
3139                m.get("PolicyText")
3140                    .and_then(Value::as_str)
3141                    .map(String::from)
3142            });
3143        Ok(AwsResponse::ok_json(json!({ "PolicyText": policy })))
3144    }
3145
3146    fn put_organization_conformance_pack(
3147        &self,
3148        account: &str,
3149        region: &str,
3150        body: &Value,
3151    ) -> Result<AwsResponse, AwsServiceError> {
3152        let name = require_str(body, "OrganizationConformancePackName")?;
3153        let arn = format!(
3154            "arn:aws:config:{region}:{account}:organization-conformance-pack/{name}-{}",
3155            short_id()
3156        );
3157        let mut st = self.state.write();
3158        let acc = st.account_mut(account);
3159        let out_arn = acc
3160            .org_conformance_packs
3161            .get(&name)
3162            .map(|p| p.arn.clone())
3163            .unwrap_or(arn);
3164        acc.org_conformance_packs.insert(
3165            name.clone(),
3166            OrganizationConformancePack {
3167                name,
3168                arn: out_arn.clone(),
3169                delivery_s3_bucket: body
3170                    .get("DeliveryS3Bucket")
3171                    .and_then(Value::as_str)
3172                    .map(String::from),
3173                delivery_s3_key_prefix: body
3174                    .get("DeliveryS3KeyPrefix")
3175                    .and_then(Value::as_str)
3176                    .map(String::from),
3177                input_parameters: body
3178                    .get("ConformancePackInputParameters")
3179                    .and_then(Value::as_array)
3180                    .cloned()
3181                    .unwrap_or_default(),
3182                template_body: body
3183                    .get("TemplateBody")
3184                    .and_then(Value::as_str)
3185                    .map(String::from),
3186                template_s3_uri: body
3187                    .get("TemplateS3Uri")
3188                    .and_then(Value::as_str)
3189                    .map(String::from),
3190                excluded_accounts: string_list(body, "ExcludedAccounts"),
3191                last_update_time: Utc::now(),
3192            },
3193        );
3194        let tags = parse_create_tags(body);
3195        if !tags.is_empty() {
3196            let entry = acc.tags.entry(out_arn.clone()).or_default();
3197            for (k, v) in tags {
3198                entry.insert(k, v);
3199            }
3200        }
3201        Ok(AwsResponse::ok_json(
3202            json!({ "OrganizationConformancePackArn": out_arn }),
3203        ))
3204    }
3205
3206    fn describe_organization_conformance_packs(
3207        &self,
3208        account: &str,
3209        body: &Value,
3210    ) -> Result<AwsResponse, AwsServiceError> {
3211        let names = string_list(body, "OrganizationConformancePackNames");
3212        let st = self.state.read();
3213        let list: Vec<Value> = st
3214            .account(account)
3215            .map(|a| {
3216                a.org_conformance_packs
3217                    .values()
3218                    .filter(|p| names.is_empty() || names.contains(&p.name))
3219                    .map(|p| {
3220                        json!({
3221                            "OrganizationConformancePackName": p.name,
3222                            "OrganizationConformancePackArn": p.arn,
3223                            "DeliveryS3Bucket": p.delivery_s3_bucket,
3224                            "DeliveryS3KeyPrefix": p.delivery_s3_key_prefix,
3225                            "ConformancePackInputParameters": p.input_parameters,
3226                            "ExcludedAccounts": p.excluded_accounts,
3227                            "LastUpdateTime": p.last_update_time.timestamp() as f64,
3228                        })
3229                    })
3230                    .collect()
3231            })
3232            .unwrap_or_default();
3233        Ok(AwsResponse::ok_json(
3234            json!({ "OrganizationConformancePacks": list }),
3235        ))
3236    }
3237
3238    fn delete_organization_conformance_pack(
3239        &self,
3240        account: &str,
3241        body: &Value,
3242    ) -> Result<AwsResponse, AwsServiceError> {
3243        let name = require_str(body, "OrganizationConformancePackName")?;
3244        let mut st = self.state.write();
3245        let acc = st.account_mut(account);
3246        if acc.org_conformance_packs.remove(&name).is_none() {
3247            return Err(no_such(
3248                "NoSuchOrganizationConformancePackException",
3249                format!("Cannot find organization conformance pack '{name}'."),
3250            ));
3251        }
3252        Ok(AwsResponse::ok_json(json!({})))
3253    }
3254
3255    fn describe_organization_conformance_pack_statuses(
3256        &self,
3257        account: &str,
3258        body: &Value,
3259    ) -> Result<AwsResponse, AwsServiceError> {
3260        let names = string_list(body, "OrganizationConformancePackNames");
3261        let now = Utc::now().timestamp() as f64;
3262        let st = self.state.read();
3263        let list: Vec<Value> = st
3264            .account(account)
3265            .map(|a| {
3266                a.org_conformance_packs
3267                    .values()
3268                    .filter(|p| names.is_empty() || names.contains(&p.name))
3269                    .map(|p| json!({ "OrganizationConformancePackName": p.name, "Status": "CREATE_SUCCESSFUL", "LastUpdateTime": now }))
3270                    .collect()
3271            })
3272            .unwrap_or_default();
3273        Ok(AwsResponse::ok_json(
3274            json!({ "OrganizationConformancePackStatuses": list }),
3275        ))
3276    }
3277
3278    fn get_organization_conformance_pack_detailed_status(
3279        &self,
3280        account: &str,
3281        body: &Value,
3282    ) -> Result<AwsResponse, AwsServiceError> {
3283        let name = require_str(body, "OrganizationConformancePackName")?;
3284        let st = self.state.read();
3285        if !st
3286            .account(account)
3287            .map(|a| a.org_conformance_packs.contains_key(&name))
3288            .unwrap_or(false)
3289        {
3290            return Err(no_such(
3291                "NoSuchOrganizationConformancePackException",
3292                format!("Cannot find organization conformance pack '{name}'."),
3293            ));
3294        }
3295        Ok(AwsResponse::ok_json(
3296            json!({ "OrganizationConformancePackDetailedStatuses": [] }),
3297        ))
3298    }
3299}
3300
3301// ─── Aggregators ────────────────────────────────────────────────────────────
3302
3303impl ConfigService {
3304    fn put_configuration_aggregator(
3305        &self,
3306        account: &str,
3307        region: &str,
3308        body: &Value,
3309    ) -> Result<AwsResponse, AwsServiceError> {
3310        let name = require_str(body, "ConfigurationAggregatorName")?;
3311        let now = Utc::now();
3312        let arn = format!(
3313            "arn:aws:config:{region}:{account}:config-aggregator/config-aggregator-{}",
3314            short_id()
3315        );
3316        let mut st = self.state.write();
3317        let acc = st.account_mut(account);
3318        let existing = acc.aggregators.get(&name);
3319        let agg = ConfigurationAggregator {
3320            name: name.clone(),
3321            arn: existing.map(|a| a.arn.clone()).unwrap_or(arn),
3322            account_aggregation_sources: body
3323                .get("AccountAggregationSources")
3324                .and_then(Value::as_array)
3325                .cloned()
3326                .unwrap_or_default(),
3327            organization_aggregation_source: body.get("OrganizationAggregationSource").cloned(),
3328            creation_time: existing.map(|a| a.creation_time).unwrap_or(now),
3329            last_updated_time: now,
3330            created_by: None,
3331        };
3332        let out = Self::aggregator_json(&agg);
3333        let arn_for_tags = agg.arn.clone();
3334        acc.aggregators.insert(name, agg);
3335        let tags = parse_create_tags(body);
3336        if !tags.is_empty() {
3337            let entry = acc.tags.entry(arn_for_tags).or_default();
3338            for (k, v) in tags {
3339                entry.insert(k, v);
3340            }
3341        }
3342        Ok(AwsResponse::ok_json(
3343            json!({ "ConfigurationAggregator": out }),
3344        ))
3345    }
3346
3347    fn aggregator_json(a: &ConfigurationAggregator) -> Value {
3348        json!({
3349            "ConfigurationAggregatorName": a.name,
3350            "ConfigurationAggregatorArn": a.arn,
3351            "AccountAggregationSources": a.account_aggregation_sources,
3352            "OrganizationAggregationSource": a.organization_aggregation_source,
3353            "CreationTime": a.creation_time.timestamp() as f64,
3354            "LastUpdatedTime": a.last_updated_time.timestamp() as f64,
3355        })
3356    }
3357
3358    fn describe_configuration_aggregators(
3359        &self,
3360        account: &str,
3361        body: &Value,
3362    ) -> Result<AwsResponse, AwsServiceError> {
3363        let names = string_list(body, "ConfigurationAggregatorNames");
3364        let st = self.state.read();
3365        let list: Vec<Value> = st
3366            .account(account)
3367            .map(|a| {
3368                a.aggregators
3369                    .values()
3370                    .filter(|g| names.is_empty() || names.contains(&g.name))
3371                    .map(Self::aggregator_json)
3372                    .collect()
3373            })
3374            .unwrap_or_default();
3375        if !names.is_empty() {
3376            for n in &names {
3377                if !st
3378                    .account(account)
3379                    .map(|a| a.aggregators.contains_key(n))
3380                    .unwrap_or(false)
3381                {
3382                    return Err(no_such(
3383                        "NoSuchConfigurationAggregatorException",
3384                        format!("The configuration aggregator '{n}' does not exist."),
3385                    ));
3386                }
3387            }
3388        }
3389        Ok(paged_response(
3390            "ConfigurationAggregators",
3391            list,
3392            body,
3393            "NextToken",
3394        ))
3395    }
3396
3397    fn delete_configuration_aggregator(
3398        &self,
3399        account: &str,
3400        body: &Value,
3401    ) -> Result<AwsResponse, AwsServiceError> {
3402        let name = require_str(body, "ConfigurationAggregatorName")?;
3403        let mut st = self.state.write();
3404        let acc = st.account_mut(account);
3405        if acc.aggregators.remove(&name).is_none() {
3406            return Err(no_such(
3407                "NoSuchConfigurationAggregatorException",
3408                format!("The configuration aggregator '{name}' does not exist."),
3409            ));
3410        }
3411        Ok(AwsResponse::ok_json(json!({})))
3412    }
3413
3414    fn describe_configuration_aggregator_sources_status(
3415        &self,
3416        account: &str,
3417        body: &Value,
3418    ) -> Result<AwsResponse, AwsServiceError> {
3419        let name = require_str(body, "ConfigurationAggregatorName")?;
3420        let now = Utc::now().timestamp() as f64;
3421        let st = self.state.read();
3422        let agg = st.account(account).and_then(|a| a.aggregators.get(&name));
3423        let Some(agg) = agg else {
3424            return Err(no_such(
3425                "NoSuchConfigurationAggregatorException",
3426                format!("The configuration aggregator '{name}' does not exist."),
3427            ));
3428        };
3429        let mut list = Vec::new();
3430        for src in &agg.account_aggregation_sources {
3431            let region = src
3432                .get("AwsRegions")
3433                .and_then(Value::as_array)
3434                .and_then(|a| a.first())
3435                .and_then(Value::as_str)
3436                .unwrap_or("us-east-1");
3437            let accounts = src
3438                .get("AccountIds")
3439                .and_then(Value::as_array)
3440                .cloned()
3441                .unwrap_or_default();
3442            for acct in accounts {
3443                list.push(json!({
3444                    "SourceId": acct,
3445                    "SourceType": "ACCOUNT",
3446                    "AwsRegion": region,
3447                    "LastUpdateStatus": "SUCCEEDED",
3448                    "LastUpdateTime": now,
3449                }));
3450            }
3451        }
3452        Ok(AwsResponse::ok_json(
3453            json!({ "AggregatedSourceStatusList": list }),
3454        ))
3455    }
3456
3457    fn put_aggregation_authorization(
3458        &self,
3459        account: &str,
3460        region: &str,
3461        body: &Value,
3462    ) -> Result<AwsResponse, AwsServiceError> {
3463        let authorized_account = require_str(body, "AuthorizedAccountId")?;
3464        let authorized_region = require_str(body, "AuthorizedAwsRegion")?;
3465        let now = Utc::now();
3466        let arn = format!("arn:aws:config:{region}:{account}:aggregation-authorization/{authorized_account}/{authorized_region}");
3467        let key = format!("{authorized_account}\u{1}{authorized_region}");
3468        let auth = AggregationAuthorization {
3469            arn: arn.clone(),
3470            authorized_account_id: authorized_account,
3471            authorized_aws_region: authorized_region,
3472            creation_time: now,
3473        };
3474        let out = Self::auth_json(&auth);
3475        let mut st = self.state.write();
3476        let acc = st.account_mut(account);
3477        acc.aggregation_authorizations.insert(key, auth);
3478        let tags = parse_create_tags(body);
3479        if !tags.is_empty() {
3480            let entry = acc.tags.entry(arn.clone()).or_default();
3481            for (k, v) in tags {
3482                entry.insert(k, v);
3483            }
3484        }
3485        let _ = out;
3486        Ok(AwsResponse::ok_json(
3487            json!({ "AggregationAuthorization": Self::auth_json_by_arn(&arn) }),
3488        ))
3489    }
3490
3491    fn auth_json(a: &AggregationAuthorization) -> Value {
3492        json!({
3493            "AggregationAuthorizationArn": a.arn,
3494            "AuthorizedAccountId": a.authorized_account_id,
3495            "AuthorizedAwsRegion": a.authorized_aws_region,
3496            "CreationTime": a.creation_time.timestamp() as f64,
3497        })
3498    }
3499
3500    fn auth_json_by_arn(arn: &str) -> Value {
3501        json!({ "AggregationAuthorizationArn": arn })
3502    }
3503
3504    fn describe_aggregation_authorizations(
3505        &self,
3506        account: &str,
3507        body: &Value,
3508    ) -> Result<AwsResponse, AwsServiceError> {
3509        let st = self.state.read();
3510        let list: Vec<Value> = st
3511            .account(account)
3512            .map(|a| {
3513                a.aggregation_authorizations
3514                    .values()
3515                    .map(Self::auth_json)
3516                    .collect()
3517            })
3518            .unwrap_or_default();
3519        Ok(paged_response(
3520            "AggregationAuthorizations",
3521            list,
3522            body,
3523            "NextToken",
3524        ))
3525    }
3526
3527    fn delete_aggregation_authorization(
3528        &self,
3529        account: &str,
3530        body: &Value,
3531    ) -> Result<AwsResponse, AwsServiceError> {
3532        let authorized_account = require_str(body, "AuthorizedAccountId")?;
3533        let authorized_region = require_str(body, "AuthorizedAwsRegion")?;
3534        let key = format!("{authorized_account}\u{1}{authorized_region}");
3535        let mut st = self.state.write();
3536        st.account_mut(account)
3537            .aggregation_authorizations
3538            .remove(&key);
3539        Ok(AwsResponse::ok_json(json!({})))
3540    }
3541
3542    /// Aggregators aggregate over the same recorded items (single-account,
3543    /// single-region deployment) — real aggregation across the account's own
3544    /// recorded state.
3545    fn batch_get_aggregate_resource_config(
3546        &self,
3547        account: &str,
3548        body: &Value,
3549    ) -> Result<AwsResponse, AwsServiceError> {
3550        let keys = body
3551            .get("ResourceIdentifiers")
3552            .and_then(Value::as_array)
3553            .cloned()
3554            .unwrap_or_default();
3555        let st = self.state.read();
3556        let acc = st.account(account);
3557        let mut items = Vec::new();
3558        let mut unprocessed = Vec::new();
3559        for k in keys {
3560            let rt = k
3561                .get("ResourceType")
3562                .and_then(Value::as_str)
3563                .unwrap_or_default();
3564            let rid = k
3565                .get("ResourceId")
3566                .and_then(Value::as_str)
3567                .unwrap_or_default();
3568            let key = resource_key(rt, rid);
3569            match acc
3570                .and_then(|a| a.config_items.get(&key))
3571                .and_then(|h| h.last())
3572            {
3573                Some(ci) => items.push(Self::config_item_json(ci)),
3574                None => unprocessed.push(k),
3575            }
3576        }
3577        Ok(AwsResponse::ok_json(
3578            json!({ "BaseConfigurationItems": items, "UnprocessedResourceIdentifiers": unprocessed }),
3579        ))
3580    }
3581
3582    fn get_aggregate_resource_config(
3583        &self,
3584        account: &str,
3585        body: &Value,
3586    ) -> Result<AwsResponse, AwsServiceError> {
3587        let ident = body
3588            .get("ResourceIdentifier")
3589            .cloned()
3590            .unwrap_or(Value::Null);
3591        let rt = ident
3592            .get("ResourceType")
3593            .and_then(Value::as_str)
3594            .unwrap_or_default();
3595        let rid = ident
3596            .get("ResourceId")
3597            .and_then(Value::as_str)
3598            .unwrap_or_default();
3599        let st = self.state.read();
3600        let key = resource_key(rt, rid);
3601        let ci = st
3602            .account(account)
3603            .and_then(|a| a.config_items.get(&key))
3604            .and_then(|h| h.last());
3605        match ci {
3606            Some(ci) => Ok(AwsResponse::ok_json(
3607                json!({ "ConfigurationItem": Self::config_item_json(ci) }),
3608            )),
3609            None => Err(no_such(
3610                "ResourceNotDiscoveredException",
3611                "Resource is not discovered by the aggregator.",
3612            )),
3613        }
3614    }
3615
3616    fn list_aggregate_discovered_resources(
3617        &self,
3618        account: &str,
3619        body: &Value,
3620    ) -> Result<AwsResponse, AwsServiceError> {
3621        let resource_type = require_str(body, "ResourceType")?;
3622        let st = self.state.read();
3623        let mut list = Vec::new();
3624        if let Some(acc) = st.account(account) {
3625            for (key, history) in &acc.config_items {
3626                let Some((rt, _)) = key.split_once('\u{1}') else {
3627                    continue;
3628                };
3629                if rt != resource_type {
3630                    continue;
3631                }
3632                let Some(ci) = history.last() else { continue };
3633                if ci.configuration_item_status.starts_with("ResourceDeleted") {
3634                    continue;
3635                }
3636                list.push(json!({
3637                    "SourceAccountId": account,
3638                    "SourceRegion": ci.aws_region,
3639                    "ResourceId": ci.resource_id,
3640                    "ResourceType": ci.resource_type,
3641                    "ResourceName": ci.resource_name,
3642                }));
3643            }
3644        }
3645        Ok(AwsResponse::ok_json(json!({ "ResourceIdentifiers": list })))
3646    }
3647
3648    fn get_aggregate_discovered_resource_counts(
3649        &self,
3650        account: &str,
3651        _body: &Value,
3652    ) -> Result<AwsResponse, AwsServiceError> {
3653        let st = self.state.read();
3654        let mut counts: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
3655        if let Some(acc) = st.account(account) {
3656            for (key, history) in &acc.config_items {
3657                let Some((rt, _)) = key.split_once('\u{1}') else {
3658                    continue;
3659                };
3660                if history
3661                    .last()
3662                    .map(|ci| ci.configuration_item_status.starts_with("ResourceDeleted"))
3663                    .unwrap_or(true)
3664                {
3665                    continue;
3666                }
3667                *counts.entry(rt.to_string()).or_insert(0) += 1;
3668            }
3669        }
3670        let total: i64 = counts.values().sum();
3671        let list: Vec<Value> = counts
3672            .into_iter()
3673            .map(|(t, c)| json!({ "ResourceType": t, "Count": c }))
3674            .collect();
3675        Ok(AwsResponse::ok_json(
3676            json!({ "TotalDiscoveredResources": total, "GroupedResourceCounts": list }),
3677        ))
3678    }
3679
3680    fn describe_aggregate_compliance_by_config_rules(
3681        &self,
3682        account: &str,
3683        _body: &Value,
3684    ) -> Result<AwsResponse, AwsServiceError> {
3685        let st = self.state.read();
3686        let mut list = Vec::new();
3687        if let Some(acc) = st.account(account) {
3688            for rule in acc.rules.values() {
3689                let c = acc
3690                    .evaluations
3691                    .get(&rule.name)
3692                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
3693                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
3694                list.push(json!({
3695                    "ConfigRuleName": rule.name,
3696                    "Compliance": { "ComplianceType": c },
3697                    "AccountId": account,
3698                    "AwsRegion": "us-east-1",
3699                }));
3700            }
3701        }
3702        Ok(AwsResponse::ok_json(
3703            json!({ "AggregateComplianceByConfigRules": list }),
3704        ))
3705    }
3706
3707    fn describe_aggregate_compliance_by_conformance_packs(
3708        &self,
3709        account: &str,
3710        _body: &Value,
3711    ) -> Result<AwsResponse, AwsServiceError> {
3712        let st = self.state.read();
3713        let list: Vec<Value> = st
3714            .account(account)
3715            .map(|a| {
3716                a.conformance_packs
3717                    .values()
3718                    .map(|p| {
3719                        let rules = Self::pack_rule_names(a, p);
3720                        let noncompliant = rules.iter().any(|r| a.evaluations.get(r).map(|m| m.values().any(|e| e.compliance_type == "NON_COMPLIANT")).unwrap_or(false));
3721                        json!({
3722                            "ConformancePackName": p.name,
3723                            "Compliance": { "ComplianceType": if noncompliant { "NON_COMPLIANT" } else { "COMPLIANT" } },
3724                            "AccountId": account,
3725                            "AwsRegion": "us-east-1",
3726                        })
3727                    })
3728                    .collect()
3729            })
3730            .unwrap_or_default();
3731        Ok(AwsResponse::ok_json(
3732            json!({ "AggregateComplianceByConformancePacks": list }),
3733        ))
3734    }
3735
3736    fn get_aggregate_compliance_details_by_config_rule(
3737        &self,
3738        account: &str,
3739        body: &Value,
3740    ) -> Result<AwsResponse, AwsServiceError> {
3741        let name = require_str(body, "ConfigRuleName")?;
3742        let st = self.state.read();
3743        let results: Vec<Value> = st
3744            .account(account)
3745            .and_then(|a| a.evaluations.get(&name))
3746            .map(|m| {
3747                m.values()
3748                    .map(|r| {
3749                        json!({
3750                            "AccountId": account,
3751                            "AwsRegion": "us-east-1",
3752                            "ComplianceType": r.compliance_type,
3753                            "ResultRecordedTime": r.result_recorded_time.timestamp() as f64,
3754                            "ConfigRuleInvokedTime": r.config_rule_invoked_time.timestamp() as f64,
3755                            "Annotation": r.annotation,
3756                            "EvaluationResultIdentifier": {
3757                                "EvaluationResultQualifier": {
3758                                    "ConfigRuleName": name,
3759                                    "ResourceType": r.resource_type,
3760                                    "ResourceId": r.resource_id,
3761                                },
3762                                "OrderingTimestamp": r.ordering_timestamp.timestamp() as f64,
3763                            },
3764                        })
3765                    })
3766                    .collect()
3767            })
3768            .unwrap_or_default();
3769        Ok(AwsResponse::ok_json(
3770            json!({ "AggregateEvaluationResults": results }),
3771        ))
3772    }
3773
3774    fn get_aggregate_config_rule_compliance_summary(
3775        &self,
3776        account: &str,
3777        _body: &Value,
3778    ) -> Result<AwsResponse, AwsServiceError> {
3779        let st = self.state.read();
3780        let mut compliant = 0;
3781        let mut noncompliant = 0;
3782        if let Some(acc) = st.account(account) {
3783            for rule in acc.rules.values() {
3784                match acc
3785                    .evaluations
3786                    .get(&rule.name)
3787                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
3788                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into())
3789                    .as_str()
3790                {
3791                    "COMPLIANT" => compliant += 1,
3792                    "NON_COMPLIANT" => noncompliant += 1,
3793                    _ => {}
3794                }
3795            }
3796        }
3797        Ok(AwsResponse::ok_json(json!({
3798            "GroupByKey": "ACCOUNT_ID",
3799            "AggregateComplianceCounts": [{
3800                "GroupName": account,
3801                "ComplianceSummary": {
3802                    "CompliantResourceCount": { "CappedCount": compliant, "CapExceeded": false },
3803                    "NonCompliantResourceCount": { "CappedCount": noncompliant, "CapExceeded": false },
3804                    "ComplianceSummaryTimestamp": Utc::now().timestamp() as f64,
3805                }
3806            }]
3807        })))
3808    }
3809
3810    fn get_aggregate_conformance_pack_compliance_summary(
3811        &self,
3812        account: &str,
3813        _body: &Value,
3814    ) -> Result<AwsResponse, AwsServiceError> {
3815        let st = self.state.read();
3816        let mut compliant = 0;
3817        let mut noncompliant = 0;
3818        if let Some(acc) = st.account(account) {
3819            for p in acc.conformance_packs.values() {
3820                let rules = Self::pack_rule_names(acc, p);
3821                let nc = rules.iter().any(|r| {
3822                    acc.evaluations
3823                        .get(r)
3824                        .map(|m| m.values().any(|e| e.compliance_type == "NON_COMPLIANT"))
3825                        .unwrap_or(false)
3826                });
3827                if nc {
3828                    noncompliant += 1;
3829                } else {
3830                    compliant += 1;
3831                }
3832            }
3833        }
3834        Ok(AwsResponse::ok_json(json!({
3835            "GroupByKey": "ACCOUNT_ID",
3836            "AggregateConformancePackComplianceSummaries": [{
3837                "GroupName": account,
3838                "ComplianceSummary": {
3839                    "CompliantConformancePackCount": compliant,
3840                    "NonCompliantConformancePackCount": noncompliant,
3841                }
3842            }]
3843        })))
3844    }
3845}
3846
3847// ─── Retention / stored queries / resource evaluations / select / tags ──────
3848
3849impl ConfigService {
3850    fn put_retention_configuration(
3851        &self,
3852        account: &str,
3853        body: &Value,
3854    ) -> Result<AwsResponse, AwsServiceError> {
3855        let days = body
3856            .get("RetentionPeriodInDays")
3857            .and_then(Value::as_i64)
3858            .ok_or_else(|| invalid("RetentionPeriodInDays is required"))?;
3859        if !(30..=2557).contains(&days) {
3860            return Err(invalid("RetentionPeriodInDays must be between 30 and 2557"));
3861        }
3862        let mut st = self.state.write();
3863        st.account_mut(account).retention_configurations.insert(
3864            "default".into(),
3865            RetentionConfiguration {
3866                name: "default".into(),
3867                retention_period_in_days: days,
3868            },
3869        );
3870        Ok(AwsResponse::ok_json(
3871            json!({ "RetentionConfiguration": { "Name": "default", "RetentionPeriodInDays": days } }),
3872        ))
3873    }
3874
3875    fn describe_retention_configurations(
3876        &self,
3877        account: &str,
3878        body: &Value,
3879    ) -> Result<AwsResponse, AwsServiceError> {
3880        let st = self.state.read();
3881        let list: Vec<Value> = st
3882            .account(account)
3883            .map(|a| a.retention_configurations.values().map(|r| json!({ "Name": r.name, "RetentionPeriodInDays": r.retention_period_in_days })).collect())
3884            .unwrap_or_default();
3885        Ok(paged_response(
3886            "RetentionConfigurations",
3887            list,
3888            body,
3889            "NextToken",
3890        ))
3891    }
3892
3893    fn delete_retention_configuration(
3894        &self,
3895        account: &str,
3896        body: &Value,
3897    ) -> Result<AwsResponse, AwsServiceError> {
3898        let name = require_str(body, "RetentionConfigurationName")?;
3899        let mut st = self.state.write();
3900        let acc = st.account_mut(account);
3901        if acc.retention_configurations.remove(&name).is_none() {
3902            return Err(no_such(
3903                "NoSuchRetentionConfigurationException",
3904                format!("Cannot find retention configuration with the specified name '{name}'."),
3905            ));
3906        }
3907        Ok(AwsResponse::ok_json(json!({})))
3908    }
3909
3910    fn put_stored_query(
3911        &self,
3912        account: &str,
3913        region: &str,
3914        body: &Value,
3915    ) -> Result<AwsResponse, AwsServiceError> {
3916        let q = body
3917            .get("StoredQuery")
3918            .cloned()
3919            .ok_or_else(|| invalid("StoredQuery is required"))?;
3920        let name = q
3921            .get("QueryName")
3922            .and_then(Value::as_str)
3923            .ok_or_else(|| invalid("QueryName is required"))?
3924            .to_string();
3925        let mut st = self.state.write();
3926        let acc = st.account_mut(account);
3927        let existing = acc.stored_queries.get(&name);
3928        let id = existing
3929            .map(|s| s.id.clone())
3930            .unwrap_or_else(|| Uuid::new_v4().to_string());
3931        let arn = existing.map(|s| s.arn.clone()).unwrap_or_else(|| {
3932            format!("arn:aws:config:{region}:{account}:stored-query/{name}/{id}")
3933        });
3934        acc.stored_queries.insert(
3935            name.clone(),
3936            StoredQuery {
3937                id: id.clone(),
3938                name,
3939                arn: arn.clone(),
3940                description: q
3941                    .get("Description")
3942                    .and_then(Value::as_str)
3943                    .map(String::from),
3944                expression: q
3945                    .get("Expression")
3946                    .and_then(Value::as_str)
3947                    .map(String::from),
3948            },
3949        );
3950        let _ = id;
3951        let tags = parse_create_tags(body);
3952        if !tags.is_empty() {
3953            let entry = acc.tags.entry(arn.clone()).or_default();
3954            for (k, v) in tags {
3955                entry.insert(k, v);
3956            }
3957        }
3958        Ok(AwsResponse::ok_json(json!({ "QueryArn": arn })))
3959    }
3960
3961    fn get_stored_query(
3962        &self,
3963        account: &str,
3964        body: &Value,
3965    ) -> Result<AwsResponse, AwsServiceError> {
3966        let name = require_str(body, "QueryName")?;
3967        let st = self.state.read();
3968        let q = st
3969            .account(account)
3970            .and_then(|a| a.stored_queries.get(&name));
3971        let Some(q) = q else {
3972            return Err(no_such(
3973                "ResourceNotFoundException",
3974                format!("The stored query '{name}' does not exist."),
3975            ));
3976        };
3977        Ok(AwsResponse::ok_json(json!({
3978            "StoredQuery": {
3979                "QueryId": q.id,
3980                "QueryArn": q.arn,
3981                "QueryName": q.name,
3982                "Description": q.description,
3983                "Expression": q.expression,
3984            }
3985        })))
3986    }
3987
3988    fn delete_stored_query(
3989        &self,
3990        account: &str,
3991        body: &Value,
3992    ) -> Result<AwsResponse, AwsServiceError> {
3993        let name = require_str(body, "QueryName")?;
3994        let mut st = self.state.write();
3995        let acc = st.account_mut(account);
3996        if acc.stored_queries.remove(&name).is_none() {
3997            return Err(no_such(
3998                "ResourceNotFoundException",
3999                format!("The stored query '{name}' does not exist."),
4000            ));
4001        }
4002        Ok(AwsResponse::ok_json(json!({})))
4003    }
4004
4005    fn list_stored_queries(
4006        &self,
4007        account: &str,
4008        body: &Value,
4009    ) -> Result<AwsResponse, AwsServiceError> {
4010        let st = self.state.read();
4011        let list: Vec<Value> = st
4012            .account(account)
4013            .map(|a| a.stored_queries.values().map(|q| json!({ "QueryId": q.id, "QueryArn": q.arn, "QueryName": q.name, "Description": q.description })).collect())
4014            .unwrap_or_default();
4015        Ok(paged_response(
4016            "StoredQueryMetadata",
4017            list,
4018            body,
4019            "NextToken",
4020        ))
4021    }
4022
4023    fn start_resource_evaluation(
4024        &self,
4025        account: &str,
4026        body: &Value,
4027    ) -> Result<AwsResponse, AwsServiceError> {
4028        let mode = body
4029            .get("EvaluationMode")
4030            .and_then(Value::as_str)
4031            .unwrap_or("DETECTIVE")
4032            .to_string();
4033        let details = body.get("ResourceDetails").cloned();
4034        // Evaluate the supplied resource configuration against the account's
4035        // managed rules for real, rather than storing a canned COMPLIANT.
4036        let (rtype, rid, config_str) = details
4037            .as_ref()
4038            .map(|d| {
4039                let rt = d
4040                    .get("ResourceType")
4041                    .and_then(Value::as_str)
4042                    .unwrap_or_default()
4043                    .to_string();
4044                let ri = d
4045                    .get("ResourceId")
4046                    .and_then(Value::as_str)
4047                    .unwrap_or_default()
4048                    .to_string();
4049                // ResourceConfiguration is a JSON document string in the AWS
4050                // API; tolerate a raw object too.
4051                let cfg = match d.get("ResourceConfiguration") {
4052                    Some(Value::String(s)) => s.clone(),
4053                    Some(v) => v.to_string(),
4054                    None => "null".to_string(),
4055                };
4056                (rt, ri, cfg)
4057            })
4058            .unwrap_or_default();
4059        let id = Uuid::new_v4().to_string();
4060        let mut st = self.state.write();
4061        let acc = st.account_mut(account);
4062        let evaluation_results = if rtype.is_empty() || rid.is_empty() {
4063            Vec::new()
4064        } else {
4065            validate::evaluate_proactive(acc, &rtype, &rid, &config_str)
4066        };
4067        acc.resource_evaluations.insert(
4068            id.clone(),
4069            ResourceEvaluation {
4070                resource_evaluation_id: id.clone(),
4071                evaluation_mode: mode,
4072                time_stamp: Utc::now(),
4073                status: "SUCCEEDED".into(),
4074                resource_details: details,
4075                evaluation_context: body.get("EvaluationContext").cloned(),
4076                evaluation_results,
4077            },
4078        );
4079        Ok(AwsResponse::ok_json(json!({ "ResourceEvaluationId": id })))
4080    }
4081
4082    fn get_resource_evaluation_summary(
4083        &self,
4084        account: &str,
4085        body: &Value,
4086    ) -> Result<AwsResponse, AwsServiceError> {
4087        let id = require_str(body, "ResourceEvaluationId")?;
4088        let st = self.state.read();
4089        let ev = st
4090            .account(account)
4091            .and_then(|a| a.resource_evaluations.get(&id));
4092        let Some(ev) = ev else {
4093            return Err(no_such(
4094                "ResourceNotFoundException",
4095                format!("ResourceEvaluationId '{id}' does not exist."),
4096            ));
4097        };
4098        let resource_type = ev
4099            .resource_details
4100            .as_ref()
4101            .and_then(|d| d.get("ResourceType"))
4102            .cloned()
4103            .unwrap_or(Value::Null);
4104        let resource_id = ev
4105            .resource_details
4106            .as_ref()
4107            .and_then(|d| d.get("ResourceId"))
4108            .cloned()
4109            .unwrap_or(Value::Null);
4110        // Real compliance folded from the recorded per-rule outcomes. No
4111        // applicable managed rule -> INSUFFICIENT_DATA, never a canned pass.
4112        let compliance = if ev.evaluation_results.is_empty() {
4113            "INSUFFICIENT_DATA".to_string()
4114        } else {
4115            fold_compliance(
4116                ev.evaluation_results
4117                    .iter()
4118                    .map(|r| r.compliance_type.as_str()),
4119            )
4120        };
4121        Ok(AwsResponse::ok_json(json!({
4122            "ResourceEvaluationId": ev.resource_evaluation_id,
4123            "EvaluationMode": ev.evaluation_mode,
4124            "EvaluationStatus": { "Status": ev.status },
4125            "EvaluationStartTimestamp": ev.time_stamp.timestamp() as f64,
4126            "Compliance": compliance,
4127            "ResourceDetails": { "ResourceId": resource_id, "ResourceType": resource_type },
4128        })))
4129    }
4130
4131    fn list_resource_evaluations(
4132        &self,
4133        account: &str,
4134        body: &Value,
4135    ) -> Result<AwsResponse, AwsServiceError> {
4136        let st = self.state.read();
4137        let list: Vec<Value> = st
4138            .account(account)
4139            .map(|a| {
4140                a.resource_evaluations
4141                    .values()
4142                    .map(|e| json!({ "ResourceEvaluationId": e.resource_evaluation_id, "EvaluationMode": e.evaluation_mode, "EvaluationStartTimestamp": e.time_stamp.timestamp() as f64 }))
4143                    .collect()
4144            })
4145            .unwrap_or_default();
4146        Ok(paged_response(
4147            "ResourceEvaluations",
4148            list,
4149            body,
4150            "NextToken",
4151        ))
4152    }
4153
4154    fn select_resource_config(
4155        &self,
4156        account: &str,
4157        body: &Value,
4158        aggregate: bool,
4159    ) -> Result<AwsResponse, AwsServiceError> {
4160        let expr = require_str(body, "Expression")?;
4161        let st = self.state.read();
4162        let empty = AccountState::default();
4163        let acc = st.account(account).unwrap_or(&empty);
4164        let rows = validate::run_select(&expr, acc)
4165            .map_err(|e| invalid(format!("Invalid SelectResourceConfig expression: {e}")))?;
4166        let results: Vec<Value> = rows.iter().map(|r| json!(r.to_string())).collect();
4167        let (page, next) = paginate(results, body);
4168        let mut out = json!({
4169            "Results": page,
4170            "QueryInfo": { "SelectFields": select_fields(&expr) },
4171        });
4172        if let Some(t) = next {
4173            out["NextToken"] = json!(t);
4174        }
4175        if aggregate {
4176            let _ = out.as_object_mut();
4177        }
4178        Ok(AwsResponse::ok_json(out))
4179    }
4180
4181    fn tag_resource(&self, account: &str, body: &Value) -> Result<AwsResponse, AwsServiceError> {
4182        let arn = require_str(body, "ResourceArn")?;
4183        let tags = body
4184            .get("Tags")
4185            .and_then(Value::as_array)
4186            .cloned()
4187            .unwrap_or_default();
4188        let mut st = self.state.write();
4189        let acc = st.account_mut(account);
4190        let entry = acc.tags.entry(arn).or_default();
4191        for t in tags {
4192            if let (Some(k), Some(v)) = (
4193                t.get("Key").and_then(Value::as_str),
4194                t.get("Value").and_then(Value::as_str),
4195            ) {
4196                entry.insert(k.to_string(), v.to_string());
4197            }
4198        }
4199        Ok(AwsResponse::ok_json(json!({})))
4200    }
4201
4202    fn untag_resource(&self, account: &str, body: &Value) -> Result<AwsResponse, AwsServiceError> {
4203        let arn = require_str(body, "ResourceArn")?;
4204        let keys = string_list(body, "TagKeys");
4205        let mut st = self.state.write();
4206        let acc = st.account_mut(account);
4207        if let Some(entry) = acc.tags.get_mut(&arn) {
4208            for k in keys {
4209                entry.remove(&k);
4210            }
4211        }
4212        Ok(AwsResponse::ok_json(json!({})))
4213    }
4214
4215    fn list_tags_for_resource(
4216        &self,
4217        account: &str,
4218        body: &Value,
4219    ) -> Result<AwsResponse, AwsServiceError> {
4220        let arn = require_str(body, "ResourceArn")?;
4221        let st = self.state.read();
4222        let list: Vec<Value> = st
4223            .account(account)
4224            .and_then(|a| a.tags.get(&arn))
4225            .map(|m| {
4226                m.iter()
4227                    .map(|(k, v)| json!({ "Key": k, "Value": v }))
4228                    .collect()
4229            })
4230            .unwrap_or_default();
4231        Ok(AwsResponse::ok_json(json!({ "Tags": list })))
4232    }
4233}
4234
4235// ─── Helpers ────────────────────────────────────────────────────────────────
4236
4237/// Parse a Put* request's create-time `Tags` (a `TagsList` of `{Key, Value}`)
4238/// into `(key, value)` pairs. Config Put ops accept Tags at create time but
4239/// historically dropped them, so ListTagsForResource returned nothing until a
4240/// follow-up TagResource (bug-hunt).
4241fn parse_create_tags(body: &Value) -> Vec<(String, String)> {
4242    body.get("Tags")
4243        .and_then(Value::as_array)
4244        .map(|arr| {
4245            arr.iter()
4246                .filter_map(|t| {
4247                    Some((
4248                        t.get("Key").and_then(Value::as_str)?.to_string(),
4249                        t.get("Value").and_then(Value::as_str)?.to_string(),
4250                    ))
4251                })
4252                .collect()
4253        })
4254        .unwrap_or_default()
4255}
4256
4257fn account_id(req: &AwsRequest) -> String {
4258    if req.account_id.is_empty() {
4259        "000000000000".to_string()
4260    } else {
4261        req.account_id.clone()
4262    }
4263}
4264
4265fn region(req: &AwsRequest) -> String {
4266    if req.region.is_empty() {
4267        "us-east-1".to_string()
4268    } else {
4269        req.region.clone()
4270    }
4271}
4272
4273fn short_id() -> String {
4274    fakecloud_core::ids::short_id(6)
4275}
4276
4277fn invalid(msg: impl Into<String>) -> AwsServiceError {
4278    AwsServiceError::aws_error(
4279        StatusCode::BAD_REQUEST,
4280        "InvalidParameterValueException",
4281        msg,
4282    )
4283}
4284
4285fn no_such(code: &str, msg: impl Into<String>) -> AwsServiceError {
4286    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
4287}
4288
4289fn require_str(body: &Value, field: &str) -> Result<String, AwsServiceError> {
4290    body.get(field)
4291        .and_then(Value::as_str)
4292        .filter(|s| !s.is_empty())
4293        .map(String::from)
4294        .ok_or_else(|| invalid(format!("{field} is required")))
4295}
4296
4297fn string_list(body: &Value, field: &str) -> Vec<String> {
4298    body.get(field)
4299        .and_then(Value::as_array)
4300        .map(|a| {
4301            a.iter()
4302                .filter_map(|v| v.as_str().map(String::from))
4303                .collect()
4304        })
4305        .unwrap_or_default()
4306}
4307
4308/// Read the page-size cap from a request body, tolerating the several field
4309/// spellings AWS Config uses across operations (`Limit`, `MaxResults`, and the
4310/// lowercase `limit` on the resource-config data-plane ops).
4311fn page_limit(body: &Value) -> Option<usize> {
4312    ["Limit", "MaxResults", "limit"]
4313        .iter()
4314        .find_map(|k| body.get(*k).and_then(Value::as_i64))
4315        .filter(|l| *l > 0)
4316        .map(|l| l as usize)
4317}
4318
4319/// Read the continuation token, tolerating both `NextToken` (control plane) and
4320/// `nextToken` (data plane) spellings.
4321fn page_token(body: &Value) -> Option<usize> {
4322    ["NextToken", "nextToken"]
4323        .iter()
4324        .find_map(|k| body.get(*k).and_then(Value::as_str))
4325        .filter(|s| !s.is_empty())
4326        .and_then(decode_page_token)
4327}
4328
4329/// Tokens are an opaque offset into the full ordered result list. They are
4330/// encoded so callers treat them as opaque; decoding a malformed token yields
4331/// `None` (the page then restarts from the beginning rather than erroring).
4332fn encode_page_token(offset: usize) -> String {
4333    format!("cfg:{offset}")
4334}
4335
4336fn decode_page_token(token: &str) -> Option<usize> {
4337    token.strip_prefix("cfg:").and_then(|n| n.parse().ok())
4338}
4339
4340/// Slice `items` to a single page given the request's `Limit`/`NextToken`.
4341/// Returns the page and the token for the next page (`None` when exhausted).
4342fn paginate(items: Vec<Value>, body: &Value) -> (Vec<Value>, Option<String>) {
4343    let total = items.len();
4344    let start = page_token(body).unwrap_or(0).min(total);
4345    let end = match page_limit(body) {
4346        Some(limit) => start.saturating_add(limit).min(total),
4347        None => total,
4348    };
4349    let next = if end < total {
4350        Some(encode_page_token(end))
4351    } else {
4352        None
4353    };
4354    (
4355        items.into_iter().skip(start).take(end - start).collect(),
4356        next,
4357    )
4358}
4359
4360/// Build a paginated list response: the list under `field`, plus a continuation
4361/// token under `token_field` (`NextToken` for the control plane, `nextToken`
4362/// for the data plane) when more items remain.
4363fn paged_response(field: &str, items: Vec<Value>, body: &Value, token_field: &str) -> AwsResponse {
4364    let (page, next) = paginate(items, body);
4365    let mut out = json!({ field: page });
4366    if let Some(t) = next {
4367        out[token_field] = json!(t);
4368    }
4369    AwsResponse::ok_json(out)
4370}
4371
4372/// Build the invoking event handed to a custom-Lambda config rule. The rule's
4373/// stored `InputParameters` are passed through verbatim as `ruleParameters` (a
4374/// JSON string), exactly as AWS Config does, defaulting to `"{}"` when the rule
4375/// declared none — a custom rule that reads its parameters must see them.
4376fn custom_rule_event(
4377    account: &str,
4378    rule_name: &str,
4379    input_parameters: Option<&str>,
4380    result_token: &str,
4381) -> Value {
4382    let invoking_event = json!({
4383        "awsAccountId": account,
4384        "configRuleName": rule_name,
4385        "messageType": "ScheduledNotification",
4386        "notificationCreationTime": Utc::now().to_rfc3339(),
4387    })
4388    .to_string();
4389    let rule_parameters = input_parameters
4390        .filter(|p| !p.is_empty())
4391        .map(String::from)
4392        .unwrap_or_else(|| "{}".to_string());
4393    json!({
4394        "invokingEvent": invoking_event,
4395        "ruleParameters": rule_parameters,
4396        "resultToken": result_token,
4397        "configRuleArn": format!("arn:aws:config:us-east-1:{account}:config-rule/{rule_name}"),
4398        "configRuleName": rule_name,
4399        "accountId": account,
4400    })
4401}
4402
4403/// Best-effort extraction of the SELECT field names for the `QueryInfo`.
4404fn select_fields(expr: &str) -> Vec<Value> {
4405    let lower = expr.to_ascii_lowercase();
4406    let Some(start) = lower.find("select ") else {
4407        return Vec::new();
4408    };
4409    let after = &expr[start + 7..];
4410    let end = after
4411        .to_ascii_lowercase()
4412        .find(" where ")
4413        .unwrap_or(after.len());
4414    after[..end]
4415        .split(',')
4416        .map(|s| json!({ "Name": s.trim() }))
4417        .collect()
4418}
4419
4420#[cfg(test)]
4421mod unit_tests {
4422    use super::*;
4423
4424    #[test]
4425    fn custom_rule_event_passes_stored_input_parameters() {
4426        let params = r#"{"maxAccessKeyAge":"90"}"#;
4427        let event = custom_rule_event("111122223333", "my-rule", Some(params), "my-rule#tok");
4428        // ruleParameters must carry the rule's stored InputParameters verbatim,
4429        // not a hardcoded "{}".
4430        assert_eq!(event["ruleParameters"], json!(params));
4431        assert_eq!(event["configRuleName"], json!("my-rule"));
4432        assert_eq!(event["accountId"], json!("111122223333"));
4433        assert_eq!(event["resultToken"], json!("my-rule#tok"));
4434    }
4435
4436    #[test]
4437    fn custom_rule_event_defaults_empty_parameters_to_empty_object() {
4438        let none = custom_rule_event("111122223333", "r", None, "r#t");
4439        assert_eq!(none["ruleParameters"], json!("{}"));
4440        let empty = custom_rule_event("111122223333", "r", Some(""), "r#t");
4441        assert_eq!(empty["ruleParameters"], json!("{}"));
4442    }
4443
4444    #[test]
4445    fn paginate_slices_and_emits_token() {
4446        let items: Vec<Value> = (0..5).map(|i| json!(i)).collect();
4447        let first_req = json!({ "Limit": 2 });
4448        let (page, next) = paginate(items.clone(), &first_req);
4449        assert_eq!(page, vec![json!(0), json!(1)]);
4450        let token = next.expect("more items remain -> token");
4451
4452        let second_req = json!({ "Limit": 2, "NextToken": token });
4453        let (page2, next2) = paginate(items.clone(), &second_req);
4454        assert_eq!(page2, vec![json!(2), json!(3)]);
4455        let token2 = next2.expect("still more");
4456
4457        let third_req = json!({ "Limit": 2, "NextToken": token2 });
4458        let (page3, next3) = paginate(items, &third_req);
4459        assert_eq!(page3, vec![json!(4)]);
4460        assert!(next3.is_none(), "last page has no token");
4461    }
4462
4463    #[test]
4464    fn paginate_no_limit_returns_all() {
4465        let items: Vec<Value> = (0..3).map(|i| json!(i)).collect();
4466        let (page, next) = paginate(items.clone(), &json!({}));
4467        assert_eq!(page, items);
4468        assert!(next.is_none());
4469    }
4470
4471    #[test]
4472    fn extract_pack_rule_names_parses_yaml_template() {
4473        let yaml = r#"
4474Resources:
4475  MyRule:
4476    Type: AWS::Config::ConfigRule
4477    Properties:
4478      ConfigRuleName: yaml-declared-rule
4479      Source:
4480        Owner: AWS
4481        SourceIdentifier: S3_BUCKET_VERSIONING_ENABLED
4482"#;
4483        let names = extract_pack_rule_names(Some(yaml));
4484        assert_eq!(names, vec!["yaml-declared-rule".to_string()]);
4485    }
4486}