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::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, cfg_rule);
1540        Ok(AwsResponse::ok_json(json!({})))
1541    }
1542
1543    fn rule_json(r: &ConfigRule) -> Value {
1544        let mut v = json!({
1545            "ConfigRuleName": r.name,
1546            "ConfigRuleArn": r.arn,
1547            "ConfigRuleId": r.rule_id,
1548            "Source": r.source,
1549            "ConfigRuleState": r.state,
1550        });
1551        if let Some(d) = &r.description {
1552            v["Description"] = json!(d);
1553        }
1554        if let Some(s) = &r.scope {
1555            v["Scope"] = s.clone();
1556        }
1557        if let Some(p) = &r.input_parameters {
1558            v["InputParameters"] = json!(p);
1559        }
1560        if let Some(f) = &r.maximum_execution_frequency {
1561            v["MaximumExecutionFrequency"] = json!(f);
1562        }
1563        if let Some(c) = &r.created_by {
1564            v["CreatedBy"] = json!(c);
1565        }
1566        if let Some(m) = &r.evaluation_modes {
1567            v["EvaluationModes"] = m.clone();
1568        }
1569        v
1570    }
1571
1572    fn describe_config_rules(
1573        &self,
1574        account: &str,
1575        body: &Value,
1576    ) -> Result<AwsResponse, AwsServiceError> {
1577        let names = string_list(body, "ConfigRuleNames");
1578        let st = self.state.read();
1579        let list: Vec<Value> = st
1580            .account(account)
1581            .map(|a| {
1582                a.rules
1583                    .values()
1584                    .filter(|r| names.is_empty() || names.contains(&r.name))
1585                    .map(Self::rule_json)
1586                    .collect()
1587            })
1588            .unwrap_or_default();
1589        if !names.is_empty() {
1590            for n in &names {
1591                if !st
1592                    .account(account)
1593                    .map(|a| a.rules.contains_key(n))
1594                    .unwrap_or(false)
1595                {
1596                    return Err(no_such(
1597                        "NoSuchConfigRuleException",
1598                        format!("The ConfigRule '{n}' provided in the request is invalid."),
1599                    ));
1600                }
1601            }
1602        }
1603        Ok(paged_response("ConfigRules", list, body, "NextToken"))
1604    }
1605
1606    fn delete_config_rule(
1607        &self,
1608        account: &str,
1609        body: &Value,
1610    ) -> Result<AwsResponse, AwsServiceError> {
1611        let name = require_str(body, "ConfigRuleName")?;
1612        let mut st = self.state.write();
1613        let acc = st.account_mut(account);
1614        if acc.rules.remove(&name).is_none() {
1615            return Err(no_such(
1616                "NoSuchConfigRuleException",
1617                format!("The ConfigRule '{name}' provided in the request is invalid."),
1618            ));
1619        }
1620        acc.evaluations.remove(&name);
1621        acc.remediation_configs.remove(&name);
1622        acc.remediation_executions
1623            .retain(|_, e| e.config_rule_name != name);
1624        Ok(AwsResponse::ok_json(json!({})))
1625    }
1626
1627    fn describe_config_rule_evaluation_status(
1628        &self,
1629        account: &str,
1630        body: &Value,
1631    ) -> Result<AwsResponse, AwsServiceError> {
1632        let names = string_list(body, "ConfigRuleNames");
1633        let st = self.state.read();
1634        let list: Vec<Value> = st
1635            .account(account)
1636            .map(|a| {
1637                a.rules
1638                    .values()
1639                    .filter(|r| names.is_empty() || names.contains(&r.name))
1640                    .map(|r| {
1641                        json!({
1642                            "ConfigRuleName": r.name,
1643                            "ConfigRuleArn": r.arn,
1644                            "ConfigRuleId": r.rule_id,
1645                            "LastSuccessfulInvocationTime": r.last_successful_invocation_time.map(|t| t.timestamp() as f64),
1646                            "LastSuccessfulEvaluationTime": r.last_successful_evaluation_time.map(|t| t.timestamp() as f64),
1647                            "FirstActivatedTime": r.first_activated_time.map(|t| t.timestamp() as f64),
1648                            "FirstEvaluationStarted": r.first_activated_time.is_some(),
1649                        })
1650                    })
1651                    .collect()
1652            })
1653            .unwrap_or_default();
1654        Ok(paged_response(
1655            "ConfigRulesEvaluationStatus",
1656            list,
1657            body,
1658            "NextToken",
1659        ))
1660    }
1661
1662    async fn start_config_rules_evaluation(
1663        &self,
1664        account: &str,
1665        _region: &str,
1666        body: &Value,
1667    ) -> Result<AwsResponse, AwsServiceError> {
1668        self.ensure_evaluated(account);
1669        // For custom-Lambda rules, invoke the referenced function.
1670        let names = string_list(body, "ConfigRuleNames");
1671        let custom_rules: Vec<(String, String, Option<String>)> = {
1672            let st = self.state.read();
1673            st.account(account)
1674                .map(|a| {
1675                    a.rules
1676                        .values()
1677                        .filter(|r| names.is_empty() || names.contains(&r.name))
1678                        .filter_map(|r| {
1679                            let owner = r.source.get("Owner").and_then(Value::as_str).unwrap_or("");
1680                            if owner != "CUSTOM_LAMBDA" {
1681                                return None;
1682                            }
1683                            let lambda_arn = r
1684                                .source
1685                                .get("SourceIdentifier")
1686                                .and_then(Value::as_str)?
1687                                .to_string();
1688                            Some((r.name.clone(), lambda_arn, r.input_parameters.clone()))
1689                        })
1690                        .collect()
1691                })
1692                .unwrap_or_default()
1693        };
1694        for (rule_name, lambda_arn, input_parameters) in custom_rules {
1695            self.invoke_custom_rule(
1696                account,
1697                &rule_name,
1698                &lambda_arn,
1699                input_parameters.as_deref(),
1700            )
1701            .await;
1702        }
1703        Ok(AwsResponse::ok_json(json!({})))
1704    }
1705
1706    /// Invoke a custom-Lambda config rule's function with a Config invoking
1707    /// event. If the function returns an array of evaluations synchronously,
1708    /// record them; otherwise the function is expected to call PutEvaluations
1709    /// with the supplied resultToken.
1710    async fn invoke_custom_rule(
1711        &self,
1712        account: &str,
1713        rule_name: &str,
1714        lambda_arn: &str,
1715        input_parameters: Option<&str>,
1716    ) {
1717        let (Some(lambda_state), Some(runtime)) =
1718            (self.lambda_state.clone(), self.container_runtime.clone())
1719        else {
1720            return;
1721        };
1722        let func_name = lambda_arn
1723            .rsplit(':')
1724            .next()
1725            .unwrap_or(lambda_arn)
1726            .to_string();
1727        let result_token = format!("{rule_name}#{}", Uuid::new_v4());
1728        let event = custom_rule_event(account, rule_name, input_parameters, &result_token);
1729        // Resolve the Lambda in the rule's own account (not just the default
1730        // account) so a custom rule in a non-default account finds its function.
1731        let resolved = {
1732            let accounts = lambda_state.read();
1733            accounts
1734                .get(account)
1735                .and_then(|state| state.functions.get(&func_name).cloned())
1736        };
1737        let Some(func) = resolved else {
1738            tracing::warn!(function = %func_name, account = %account, "Config custom rule Lambda not found");
1739            return;
1740        };
1741        let payload = event.to_string().into_bytes();
1742        match runtime.invoke(&func, &payload, &[]).await {
1743            Ok(resp) => {
1744                // If the function returned evaluations directly, record them.
1745                if let Ok(v) = serde_json::from_slice::<Value>(&resp) {
1746                    if let Some(arr) = v
1747                        .as_array()
1748                        .or_else(|| v.get("evaluations").and_then(Value::as_array))
1749                    {
1750                        self.record_evaluations(account, rule_name, arr);
1751                    }
1752                }
1753            }
1754            Err(e) => {
1755                tracing::warn!(function = %func_name, error = %e, "Config custom rule Lambda invocation failed")
1756            }
1757        }
1758    }
1759
1760    fn record_evaluations(&self, account: &str, rule_name: &str, evals: &[Value]) {
1761        let now = Utc::now();
1762        let mut st = self.state.write();
1763        let acc = st.account_mut(account);
1764        let entry = acc.evaluations.entry(rule_name.to_string()).or_default();
1765        for e in evals {
1766            let rt = e
1767                .get("ComplianceResourceType")
1768                .and_then(Value::as_str)
1769                .unwrap_or_default()
1770                .to_string();
1771            let rid = e
1772                .get("ComplianceResourceId")
1773                .and_then(Value::as_str)
1774                .unwrap_or_default()
1775                .to_string();
1776            let ct = e
1777                .get("ComplianceType")
1778                .and_then(Value::as_str)
1779                .unwrap_or("INSUFFICIENT_DATA")
1780                .to_string();
1781            let key = resource_key(&rt, &rid);
1782            entry.insert(
1783                key,
1784                EvaluationResult {
1785                    resource_type: rt,
1786                    resource_id: rid,
1787                    rule_name: rule_name.to_string(),
1788                    compliance_type: ct,
1789                    annotation: e
1790                        .get("Annotation")
1791                        .and_then(Value::as_str)
1792                        .map(String::from),
1793                    result_recorded_time: now,
1794                    config_rule_invoked_time: now,
1795                    ordering_timestamp: now,
1796                },
1797            );
1798        }
1799        if let Some(rule) = acc.rules.get_mut(rule_name) {
1800            rule.last_successful_evaluation_time = Some(now);
1801            rule.last_successful_invocation_time = Some(now);
1802            if rule.first_activated_time.is_none() {
1803                rule.first_activated_time = Some(now);
1804            }
1805        }
1806    }
1807
1808    fn put_evaluations(&self, account: &str, body: &Value) -> Result<AwsResponse, AwsServiceError> {
1809        let token = require_str(body, "ResultToken")?;
1810        let rule_name = token.split('#').next().unwrap_or(&token).to_string();
1811        let evals = body
1812            .get("Evaluations")
1813            .and_then(Value::as_array)
1814            .cloned()
1815            .unwrap_or_default();
1816        let test_mode = body
1817            .get("TestMode")
1818            .and_then(Value::as_bool)
1819            .unwrap_or(false);
1820        if !test_mode {
1821            let now = Utc::now();
1822            let mut st = self.state.write();
1823            let acc = st.account_mut(account);
1824            let entry = acc.evaluations.entry(rule_name.clone()).or_default();
1825            for e in &evals {
1826                let rt = e
1827                    .get("ComplianceResourceType")
1828                    .and_then(Value::as_str)
1829                    .unwrap_or_default()
1830                    .to_string();
1831                let rid = e
1832                    .get("ComplianceResourceId")
1833                    .and_then(Value::as_str)
1834                    .unwrap_or_default()
1835                    .to_string();
1836                let ct = e
1837                    .get("ComplianceType")
1838                    .and_then(Value::as_str)
1839                    .unwrap_or("INSUFFICIENT_DATA")
1840                    .to_string();
1841                let key = resource_key(&rt, &rid);
1842                entry.insert(
1843                    key,
1844                    EvaluationResult {
1845                        resource_type: rt,
1846                        resource_id: rid,
1847                        rule_name: rule_name.clone(),
1848                        compliance_type: ct,
1849                        annotation: e
1850                            .get("Annotation")
1851                            .and_then(Value::as_str)
1852                            .map(String::from),
1853                        result_recorded_time: now,
1854                        config_rule_invoked_time: now,
1855                        ordering_timestamp: now,
1856                    },
1857                );
1858            }
1859        }
1860        Ok(AwsResponse::ok_json(json!({ "FailedEvaluations": [] })))
1861    }
1862
1863    fn put_external_evaluation(
1864        &self,
1865        account: &str,
1866        body: &Value,
1867    ) -> Result<AwsResponse, AwsServiceError> {
1868        let rule_name = require_str(body, "ConfigRuleName")?;
1869        let e = body
1870            .get("ExternalEvaluation")
1871            .cloned()
1872            .ok_or_else(|| invalid("ExternalEvaluation is required"))?;
1873        let rt = e
1874            .get("ComplianceResourceType")
1875            .and_then(Value::as_str)
1876            .unwrap_or_default()
1877            .to_string();
1878        let rid = e
1879            .get("ComplianceResourceId")
1880            .and_then(Value::as_str)
1881            .unwrap_or_default()
1882            .to_string();
1883        let ct = e
1884            .get("ComplianceType")
1885            .and_then(Value::as_str)
1886            .unwrap_or("INSUFFICIENT_DATA")
1887            .to_string();
1888        let now = Utc::now();
1889        let mut st = self.state.write();
1890        let acc = st.account_mut(account);
1891        let key = resource_key(&rt, &rid);
1892        acc.evaluations
1893            .entry(rule_name.clone())
1894            .or_default()
1895            .insert(
1896                key,
1897                EvaluationResult {
1898                    resource_type: rt,
1899                    resource_id: rid,
1900                    rule_name,
1901                    compliance_type: ct,
1902                    annotation: e
1903                        .get("Annotation")
1904                        .and_then(Value::as_str)
1905                        .map(String::from),
1906                    result_recorded_time: now,
1907                    config_rule_invoked_time: now,
1908                    ordering_timestamp: now,
1909                },
1910            );
1911        Ok(AwsResponse::ok_json(json!({})))
1912    }
1913
1914    fn delete_evaluation_results(
1915        &self,
1916        account: &str,
1917        body: &Value,
1918    ) -> Result<AwsResponse, AwsServiceError> {
1919        let name = require_str(body, "ConfigRuleName")?;
1920        let mut st = self.state.write();
1921        st.account_mut(account).evaluations.remove(&name);
1922        Ok(AwsResponse::ok_json(json!({})))
1923    }
1924
1925    fn get_custom_rule_policy(
1926        &self,
1927        account: &str,
1928        body: &Value,
1929    ) -> Result<AwsResponse, AwsServiceError> {
1930        let name = require_str(body, "ConfigRuleName")?;
1931        let st = self.state.read();
1932        let policy = st
1933            .account(account)
1934            .and_then(|a| a.rules.get(&name))
1935            .and_then(|r| {
1936                r.source
1937                    .pointer("/CustomPolicyDetails/PolicyText")
1938                    .and_then(Value::as_str)
1939                    .map(String::from)
1940            });
1941        Ok(AwsResponse::ok_json(json!({ "PolicyText": policy })))
1942    }
1943}
1944
1945// ─── Compliance ─────────────────────────────────────────────────────────────
1946
1947/// Fold a set of per-resource compliance types into a single rule/resource
1948/// verdict: NON_COMPLIANT if any is, else COMPLIANT if any is, else
1949/// INSUFFICIENT_DATA.
1950fn fold_compliance<'a>(types: impl Iterator<Item = &'a str>) -> String {
1951    let mut any_compliant = false;
1952    let mut any_noncompliant = false;
1953    for t in types {
1954        match t {
1955            "NON_COMPLIANT" => any_noncompliant = true,
1956            "COMPLIANT" => any_compliant = true,
1957            _ => {}
1958        }
1959    }
1960    if any_noncompliant {
1961        "NON_COMPLIANT".into()
1962    } else if any_compliant {
1963        "COMPLIANT".into()
1964    } else {
1965        "INSUFFICIENT_DATA".into()
1966    }
1967}
1968
1969impl ConfigService {
1970    fn describe_compliance_by_config_rule(
1971        &self,
1972        account: &str,
1973        body: &Value,
1974    ) -> Result<AwsResponse, AwsServiceError> {
1975        let names = string_list(body, "ConfigRuleNames");
1976        let filter = string_list(body, "ComplianceTypes");
1977        let st = self.state.read();
1978        let mut list = Vec::new();
1979        if let Some(acc) = st.account(account) {
1980            for rule in acc.rules.values() {
1981                if !names.is_empty() && !names.contains(&rule.name) {
1982                    continue;
1983                }
1984                let results = acc.evaluations.get(&rule.name);
1985                let compliance = results
1986                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
1987                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
1988                if !filter.is_empty() && !filter.contains(&compliance) {
1989                    continue;
1990                }
1991                list.push(json!({
1992                    "ConfigRuleName": rule.name,
1993                    "Compliance": { "ComplianceType": compliance },
1994                }));
1995            }
1996        }
1997        Ok(paged_response(
1998            "ComplianceByConfigRules",
1999            list,
2000            body,
2001            "NextToken",
2002        ))
2003    }
2004
2005    fn describe_compliance_by_resource(
2006        &self,
2007        account: &str,
2008        body: &Value,
2009    ) -> Result<AwsResponse, AwsServiceError> {
2010        let rtype = body.get("ResourceType").and_then(Value::as_str);
2011        let rid = body.get("ResourceId").and_then(Value::as_str);
2012        let filter = string_list(body, "ComplianceTypes");
2013        let st = self.state.read();
2014        // Aggregate per resource across all rules.
2015        let mut per_resource: std::collections::BTreeMap<(String, String), Vec<String>> =
2016            std::collections::BTreeMap::new();
2017        if let Some(acc) = st.account(account) {
2018            for results in acc.evaluations.values() {
2019                for r in results.values() {
2020                    if r.resource_type.is_empty() {
2021                        continue;
2022                    }
2023                    if let Some(t) = rtype {
2024                        if r.resource_type != t {
2025                            continue;
2026                        }
2027                    }
2028                    if let Some(i) = rid {
2029                        if r.resource_id != i {
2030                            continue;
2031                        }
2032                    }
2033                    per_resource
2034                        .entry((r.resource_type.clone(), r.resource_id.clone()))
2035                        .or_default()
2036                        .push(r.compliance_type.clone());
2037                }
2038            }
2039        }
2040        let mut list = Vec::new();
2041        for ((rt, ri), types) in per_resource {
2042            let compliance = fold_compliance(types.iter().map(String::as_str));
2043            if !filter.is_empty() && !filter.contains(&compliance) {
2044                continue;
2045            }
2046            list.push(json!({
2047                "ResourceType": rt,
2048                "ResourceId": ri,
2049                "Compliance": { "ComplianceType": compliance },
2050            }));
2051        }
2052        Ok(paged_response(
2053            "ComplianceByResources",
2054            list,
2055            body,
2056            "NextToken",
2057        ))
2058    }
2059
2060    fn get_compliance_details_by_config_rule(
2061        &self,
2062        account: &str,
2063        body: &Value,
2064    ) -> Result<AwsResponse, AwsServiceError> {
2065        let name = require_str(body, "ConfigRuleName")?;
2066        let filter = string_list(body, "ComplianceTypes");
2067        let st = self.state.read();
2068        let results: Vec<Value> = st
2069            .account(account)
2070            .and_then(|a| a.evaluations.get(&name))
2071            .map(|m| {
2072                m.values()
2073                    .filter(|r| filter.is_empty() || filter.contains(&r.compliance_type))
2074                    .map(|r| Self::evaluation_result_json(r, &name))
2075                    .collect()
2076            })
2077            .unwrap_or_default();
2078        Ok(paged_response(
2079            "EvaluationResults",
2080            results,
2081            body,
2082            "NextToken",
2083        ))
2084    }
2085
2086    fn get_compliance_details_by_resource(
2087        &self,
2088        account: &str,
2089        body: &Value,
2090    ) -> Result<AwsResponse, AwsServiceError> {
2091        let rtype = body
2092            .get("ResourceType")
2093            .and_then(Value::as_str)
2094            .unwrap_or_default()
2095            .to_string();
2096        let rid = body
2097            .get("ResourceId")
2098            .and_then(Value::as_str)
2099            .unwrap_or_default()
2100            .to_string();
2101        let filter = string_list(body, "ComplianceTypes");
2102        let st = self.state.read();
2103        let mut results = Vec::new();
2104        if let Some(acc) = st.account(account) {
2105            for (rule_name, m) in &acc.evaluations {
2106                for r in m.values() {
2107                    if r.resource_type == rtype
2108                        && r.resource_id == rid
2109                        && (filter.is_empty() || filter.contains(&r.compliance_type))
2110                    {
2111                        results.push(Self::evaluation_result_json(r, rule_name));
2112                    }
2113                }
2114            }
2115        }
2116        Ok(paged_response(
2117            "EvaluationResults",
2118            results,
2119            body,
2120            "NextToken",
2121        ))
2122    }
2123
2124    fn evaluation_result_json(r: &EvaluationResult, rule_name: &str) -> Value {
2125        json!({
2126            "EvaluationResultIdentifier": {
2127                "EvaluationResultQualifier": {
2128                    "ConfigRuleName": rule_name,
2129                    "ResourceType": r.resource_type,
2130                    "ResourceId": r.resource_id,
2131                },
2132                "OrderingTimestamp": r.ordering_timestamp.timestamp() as f64,
2133            },
2134            "ComplianceType": r.compliance_type,
2135            "ResultRecordedTime": r.result_recorded_time.timestamp() as f64,
2136            "ConfigRuleInvokedTime": r.config_rule_invoked_time.timestamp() as f64,
2137            "Annotation": r.annotation,
2138        })
2139    }
2140
2141    fn get_compliance_summary_by_config_rule(
2142        &self,
2143        account: &str,
2144    ) -> Result<AwsResponse, AwsServiceError> {
2145        let st = self.state.read();
2146        let mut compliant = 0;
2147        let mut noncompliant = 0;
2148        if let Some(acc) = st.account(account) {
2149            for rule in acc.rules.values() {
2150                let c = acc
2151                    .evaluations
2152                    .get(&rule.name)
2153                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
2154                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
2155                match c.as_str() {
2156                    "COMPLIANT" => compliant += 1,
2157                    "NON_COMPLIANT" => noncompliant += 1,
2158                    _ => {}
2159                }
2160            }
2161        }
2162        Ok(AwsResponse::ok_json(json!({
2163            "ComplianceSummary": {
2164                "CompliantResourceCount": { "CappedCount": compliant, "CapExceeded": false },
2165                "NonCompliantResourceCount": { "CappedCount": noncompliant, "CapExceeded": false },
2166                "ComplianceSummaryTimestamp": Utc::now().timestamp() as f64,
2167            }
2168        })))
2169    }
2170
2171    fn get_compliance_summary_by_resource_type(
2172        &self,
2173        account: &str,
2174        body: &Value,
2175    ) -> Result<AwsResponse, AwsServiceError> {
2176        let types = string_list(body, "ResourceTypes");
2177        let st = self.state.read();
2178        // Collect the folded compliance per resource, grouped by resource type.
2179        // A resource type is COMPLIANT/NON_COMPLIANT once per distinct resource
2180        // (folded across every rule that evaluated it).
2181        let mut per_type_resources: std::collections::BTreeMap<
2182            String,
2183            std::collections::BTreeMap<String, Vec<String>>,
2184        > = std::collections::BTreeMap::new();
2185        if let Some(acc) = st.account(account) {
2186            for m in acc.evaluations.values() {
2187                for r in m.values() {
2188                    if r.resource_type.is_empty() {
2189                        continue;
2190                    }
2191                    if !types.is_empty() && !types.contains(&r.resource_type) {
2192                        continue;
2193                    }
2194                    per_type_resources
2195                        .entry(r.resource_type.clone())
2196                        .or_default()
2197                        .entry(r.resource_id.clone())
2198                        .or_default()
2199                        .push(r.compliance_type.clone());
2200                }
2201            }
2202        }
2203        let now = Utc::now().timestamp() as f64;
2204        let summary_json = |resource_type: &str,
2205                            resources: &std::collections::BTreeMap<String, Vec<String>>|
2206         -> Value {
2207            let mut compliant = 0;
2208            let mut noncompliant = 0;
2209            for c in resources.values() {
2210                match fold_compliance(c.iter().map(String::as_str)).as_str() {
2211                    "COMPLIANT" => compliant += 1,
2212                    "NON_COMPLIANT" => noncompliant += 1,
2213                    _ => {}
2214                }
2215            }
2216            let summary = json!({
2217                "CompliantResourceCount": { "CappedCount": compliant, "CapExceeded": false },
2218                "NonCompliantResourceCount": { "CappedCount": noncompliant, "CapExceeded": false },
2219                "ComplianceSummaryTimestamp": now,
2220            });
2221            if resource_type.is_empty() {
2222                json!({ "ComplianceSummary": summary })
2223            } else {
2224                json!({ "ResourceType": resource_type, "ComplianceSummary": summary })
2225            }
2226        };
2227        let list: Vec<Value> = if types.is_empty() {
2228            // No filter: one aggregated summary over all resource types, with no
2229            // ResourceType label (matches AWS).
2230            let mut all: std::collections::BTreeMap<String, Vec<String>> =
2231                std::collections::BTreeMap::new();
2232            for resources in per_type_resources.values() {
2233                for (rid, c) in resources {
2234                    all.entry(rid.clone())
2235                        .or_default()
2236                        .extend(c.iter().cloned());
2237                }
2238            }
2239            vec![summary_json("", &all)]
2240        } else {
2241            // One summary per requested resource type (empty when the type has
2242            // no evaluated resources).
2243            types
2244                .iter()
2245                .map(|t| {
2246                    let empty = std::collections::BTreeMap::new();
2247                    let resources = per_type_resources.get(t).unwrap_or(&empty);
2248                    summary_json(t, resources)
2249                })
2250                .collect()
2251        };
2252        Ok(AwsResponse::ok_json(json!({
2253            "ComplianceSummariesByResourceType": list
2254        })))
2255    }
2256}
2257
2258// ─── Remediation ────────────────────────────────────────────────────────────
2259
2260impl ConfigService {
2261    fn put_remediation_configurations(
2262        &self,
2263        account: &str,
2264        region: &str,
2265        body: &Value,
2266    ) -> Result<AwsResponse, AwsServiceError> {
2267        let configs = body
2268            .get("RemediationConfigurations")
2269            .and_then(Value::as_array)
2270            .cloned()
2271            .unwrap_or_default();
2272        let mut failed = Vec::new();
2273        let mut st = self.state.write();
2274        let acc = st.account_mut(account);
2275        for c in &configs {
2276            let rule_name = match c.get("ConfigRuleName").and_then(Value::as_str) {
2277                Some(n) => n.to_string(),
2278                None => {
2279                    failed.push(json!({ "FailureMessage": "ConfigRuleName is required", "FailedItems": [c] }));
2280                    continue;
2281                }
2282            };
2283            acc.remediation_configs.insert(
2284                rule_name.clone(),
2285                RemediationConfiguration {
2286                    config_rule_name: rule_name.clone(),
2287                    arn: format!("arn:aws:config:{region}:{account}:remediation-configuration/{rule_name}/{}", short_id()),
2288                    target_type: c.get("TargetType").and_then(Value::as_str).unwrap_or("SSM_DOCUMENT").to_string(),
2289                    target_id: c.get("TargetId").and_then(Value::as_str).unwrap_or_default().to_string(),
2290                    target_version: c.get("TargetVersion").and_then(Value::as_str).map(String::from),
2291                    parameters: c.get("Parameters").cloned(),
2292                    resource_type: c.get("ResourceType").and_then(Value::as_str).map(String::from),
2293                    automatic: c.get("Automatic").and_then(Value::as_bool).unwrap_or(false),
2294                    execution_controls: c.get("ExecutionControls").cloned(),
2295                    maximum_automatic_attempts: c.get("MaximumAutomaticAttempts").and_then(Value::as_i64),
2296                    retry_attempt_seconds: c.get("RetryAttemptSeconds").and_then(Value::as_i64),
2297                    created_by_service: None,
2298                },
2299            );
2300        }
2301        Ok(AwsResponse::ok_json(json!({ "FailedBatches": failed })))
2302    }
2303
2304    fn remediation_json(r: &RemediationConfiguration) -> Value {
2305        let mut v = json!({
2306            "ConfigRuleName": r.config_rule_name,
2307            "TargetType": r.target_type,
2308            "TargetId": r.target_id,
2309            "Arn": r.arn,
2310            "Automatic": r.automatic,
2311        });
2312        if let Some(t) = &r.target_version {
2313            v["TargetVersion"] = json!(t);
2314        }
2315        if let Some(p) = &r.parameters {
2316            v["Parameters"] = p.clone();
2317        }
2318        if let Some(rt) = &r.resource_type {
2319            v["ResourceType"] = json!(rt);
2320        }
2321        if let Some(e) = &r.execution_controls {
2322            v["ExecutionControls"] = e.clone();
2323        }
2324        if let Some(m) = r.maximum_automatic_attempts {
2325            v["MaximumAutomaticAttempts"] = json!(m);
2326        }
2327        if let Some(s) = r.retry_attempt_seconds {
2328            v["RetryAttemptSeconds"] = json!(s);
2329        }
2330        v
2331    }
2332
2333    fn describe_remediation_configurations(
2334        &self,
2335        account: &str,
2336        body: &Value,
2337    ) -> Result<AwsResponse, AwsServiceError> {
2338        let names = string_list(body, "ConfigRuleNames");
2339        let st = self.state.read();
2340        let list: Vec<Value> = st
2341            .account(account)
2342            .map(|a| {
2343                a.remediation_configs
2344                    .values()
2345                    .filter(|r| names.is_empty() || names.contains(&r.config_rule_name))
2346                    .map(Self::remediation_json)
2347                    .collect()
2348            })
2349            .unwrap_or_default();
2350        Ok(AwsResponse::ok_json(
2351            json!({ "RemediationConfigurations": list }),
2352        ))
2353    }
2354
2355    fn delete_remediation_configuration(
2356        &self,
2357        account: &str,
2358        body: &Value,
2359    ) -> Result<AwsResponse, AwsServiceError> {
2360        let name = require_str(body, "ConfigRuleName")?;
2361        let mut st = self.state.write();
2362        let acc = st.account_mut(account);
2363        if acc.remediation_configs.remove(&name).is_none() {
2364            return Err(no_such(
2365                "NoSuchRemediationConfigurationException",
2366                "No RemediationConfiguration for rule exists.",
2367            ));
2368        }
2369        acc.remediation_executions
2370            .retain(|_, e| e.config_rule_name != name);
2371        Ok(AwsResponse::ok_json(json!({})))
2372    }
2373
2374    fn put_remediation_exceptions(
2375        &self,
2376        account: &str,
2377        body: &Value,
2378    ) -> Result<AwsResponse, AwsServiceError> {
2379        let rule_name = require_str(body, "ConfigRuleName")?;
2380        let keys = body
2381            .get("ResourceKeys")
2382            .and_then(Value::as_array)
2383            .cloned()
2384            .unwrap_or_default();
2385        let message = body
2386            .get("Message")
2387            .and_then(Value::as_str)
2388            .map(String::from);
2389        let mut st = self.state.write();
2390        let acc = st.account_mut(account);
2391        for k in &keys {
2392            let rt = k
2393                .get("ResourceType")
2394                .and_then(Value::as_str)
2395                .unwrap_or_default()
2396                .to_string();
2397            let rid = k
2398                .get("ResourceId")
2399                .and_then(Value::as_str)
2400                .unwrap_or_default()
2401                .to_string();
2402            let key = format!("{rule_name}\u{1}{rt}\u{1}{rid}");
2403            acc.remediation_exceptions.insert(
2404                key,
2405                RemediationException {
2406                    config_rule_name: rule_name.clone(),
2407                    resource_type: rt,
2408                    resource_id: rid,
2409                    message: message.clone(),
2410                    expiration_time: None,
2411                },
2412            );
2413        }
2414        Ok(AwsResponse::ok_json(json!({ "FailedBatches": [] })))
2415    }
2416
2417    fn describe_remediation_exceptions(
2418        &self,
2419        account: &str,
2420        body: &Value,
2421    ) -> Result<AwsResponse, AwsServiceError> {
2422        let rule_name = require_str(body, "ConfigRuleName")?;
2423        let st = self.state.read();
2424        let list: Vec<Value> = st
2425            .account(account)
2426            .map(|a| {
2427                a.remediation_exceptions
2428                    .values()
2429                    .filter(|e| e.config_rule_name == rule_name)
2430                    .map(|e| {
2431                        json!({
2432                            "ConfigRuleName": e.config_rule_name,
2433                            "ResourceType": e.resource_type,
2434                            "ResourceId": e.resource_id,
2435                            "Message": e.message,
2436                        })
2437                    })
2438                    .collect()
2439            })
2440            .unwrap_or_default();
2441        Ok(paged_response(
2442            "RemediationExceptions",
2443            list,
2444            body,
2445            "NextToken",
2446        ))
2447    }
2448
2449    fn delete_remediation_exceptions(
2450        &self,
2451        account: &str,
2452        body: &Value,
2453    ) -> Result<AwsResponse, AwsServiceError> {
2454        let rule_name = require_str(body, "ConfigRuleName")?;
2455        let keys = body
2456            .get("ResourceKeys")
2457            .and_then(Value::as_array)
2458            .cloned()
2459            .unwrap_or_default();
2460        let mut st = self.state.write();
2461        let acc = st.account_mut(account);
2462        for k in &keys {
2463            let rt = k
2464                .get("ResourceType")
2465                .and_then(Value::as_str)
2466                .unwrap_or_default();
2467            let rid = k
2468                .get("ResourceId")
2469                .and_then(Value::as_str)
2470                .unwrap_or_default();
2471            acc.remediation_exceptions
2472                .remove(&format!("{rule_name}\u{1}{rt}\u{1}{rid}"));
2473        }
2474        Ok(AwsResponse::ok_json(json!({ "FailedBatches": [] })))
2475    }
2476
2477    fn start_remediation_execution(
2478        &self,
2479        account: &str,
2480        body: &Value,
2481    ) -> Result<AwsResponse, AwsServiceError> {
2482        let rule_name = require_str(body, "ConfigRuleName")?;
2483        let keys = body
2484            .get("ResourceKeys")
2485            .and_then(Value::as_array)
2486            .cloned()
2487            .unwrap_or_default();
2488        let now = Utc::now();
2489        let mut st = self.state.write();
2490        let acc = st.account_mut(account);
2491        if !acc.remediation_configs.contains_key(&rule_name) {
2492            return Err(no_such(
2493                "NoSuchRemediationConfigurationException",
2494                "No RemediationConfiguration for rule exists.",
2495            ));
2496        }
2497        // Record an execution per resource key. The SSM Automation the target
2498        // document would drive is not simulated here, so the execution is
2499        // recorded as QUEUED (accepted, outcome unknown) rather than a
2500        // fabricated SUCCEEDED.
2501        let mut failed = Vec::new();
2502        for k in &keys {
2503            let (Some(rt), Some(rid)) = (
2504                k.get("resourceType")
2505                    .or_else(|| k.get("ResourceType"))
2506                    .and_then(Value::as_str),
2507                k.get("resourceId")
2508                    .or_else(|| k.get("ResourceId"))
2509                    .and_then(Value::as_str),
2510            ) else {
2511                failed.push(k.clone());
2512                continue;
2513            };
2514            let key = format!("{rule_name}\u{1}{rt}\u{1}{rid}");
2515            acc.remediation_executions.insert(
2516                key,
2517                RemediationExecutionStatus {
2518                    config_rule_name: rule_name.clone(),
2519                    resource_type: rt.to_string(),
2520                    resource_id: rid.to_string(),
2521                    state: "QUEUED".into(),
2522                    invocation_time: now,
2523                    last_updated_time: now,
2524                },
2525            );
2526        }
2527        Ok(AwsResponse::ok_json(json!({ "FailedItems": failed })))
2528    }
2529
2530    fn describe_remediation_execution_status(
2531        &self,
2532        account: &str,
2533        body: &Value,
2534    ) -> Result<AwsResponse, AwsServiceError> {
2535        let rule_name = require_str(body, "ConfigRuleName")?;
2536        let keys = body
2537            .get("ResourceKeys")
2538            .and_then(Value::as_array)
2539            .cloned()
2540            .unwrap_or_default();
2541        // Restrict to the requested resource keys, when any were supplied.
2542        let wanted: Vec<(String, String)> = keys
2543            .iter()
2544            .filter_map(|k| {
2545                let rt = k
2546                    .get("resourceType")
2547                    .or_else(|| k.get("ResourceType"))
2548                    .and_then(Value::as_str)?;
2549                let rid = k
2550                    .get("resourceId")
2551                    .or_else(|| k.get("ResourceId"))
2552                    .and_then(Value::as_str)?;
2553                Some((rt.to_string(), rid.to_string()))
2554            })
2555            .collect();
2556        let st = self.state.read();
2557        let list: Vec<Value> = st
2558            .account(account)
2559            .map(|a| {
2560                a.remediation_executions
2561                    .values()
2562                    .filter(|e| e.config_rule_name == rule_name)
2563                    .filter(|e| {
2564                        wanted.is_empty()
2565                            || wanted
2566                                .iter()
2567                                .any(|(rt, rid)| *rt == e.resource_type && *rid == e.resource_id)
2568                    })
2569                    .map(|e| {
2570                        let inv = e.invocation_time.timestamp() as f64;
2571                        let upd = e.last_updated_time.timestamp() as f64;
2572                        json!({
2573                            "ResourceKey": { "resourceType": e.resource_type, "resourceId": e.resource_id },
2574                            "State": e.state,
2575                            "StepDetails": [{ "Name": "remediate", "State": e.state, "StartTime": inv }],
2576                            "InvocationTime": inv,
2577                            "LastUpdatedTime": upd,
2578                        })
2579                    })
2580                    .collect()
2581            })
2582            .unwrap_or_default();
2583        Ok(paged_response(
2584            "RemediationExecutionStatuses",
2585            list,
2586            body,
2587            "NextToken",
2588        ))
2589    }
2590}
2591
2592// ─── Conformance packs ──────────────────────────────────────────────────────
2593
2594/// Extract the `ConfigRuleName`s declared by a conformance-pack template body.
2595/// Used to scope conformance-pack compliance to the pack's own rules. Both JSON
2596/// and YAML CloudFormation templates are parsed (conformance-pack templates are
2597/// most commonly authored in YAML); a template with no parseable rule names
2598/// yields an empty list (compliance then aggregates over all account rules).
2599fn extract_pack_rule_names(template: Option<&str>) -> Vec<String> {
2600    let Some(t) = template else { return Vec::new() };
2601    // Try JSON first, then YAML (a superset that also accepts JSON, but JSON is
2602    // cheaper and the common case for programmatically-generated templates).
2603    let v: Value = serde_json::from_str(t)
2604        .or_else(|_| serde_yaml::from_str::<Value>(t))
2605        .unwrap_or(Value::Null);
2606    let Some(resources) = v.get("Resources").and_then(Value::as_object) else {
2607        return Vec::new();
2608    };
2609    resources
2610        .values()
2611        .filter(|r| r.get("Type").and_then(Value::as_str) == Some("AWS::Config::ConfigRule"))
2612        .filter_map(|r| {
2613            r.pointer("/Properties/ConfigRuleName")
2614                .and_then(Value::as_str)
2615                .map(String::from)
2616        })
2617        .collect()
2618}
2619
2620impl ConfigService {
2621    fn put_conformance_pack(
2622        &self,
2623        account: &str,
2624        region: &str,
2625        body: &Value,
2626    ) -> Result<AwsResponse, AwsServiceError> {
2627        let name = require_str(body, "ConformancePackName")?;
2628        let template_body = body
2629            .get("TemplateBody")
2630            .and_then(Value::as_str)
2631            .map(String::from);
2632        let template_s3_uri = body
2633            .get("TemplateS3Uri")
2634            .and_then(Value::as_str)
2635            .map(String::from);
2636        if template_body.is_none()
2637            && template_s3_uri.is_none()
2638            && body.get("TemplateSSMDocumentDetails").is_none()
2639        {
2640            return Err(invalid(
2641                "Either TemplateBody, TemplateS3Uri, or TemplateSSMDocumentDetails is required",
2642            ));
2643        }
2644        let now = Utc::now();
2645        let arn = format!(
2646            "arn:aws:config:{region}:{account}:conformance-pack/{name}-{}",
2647            short_id()
2648        );
2649        let id = format!("conformance-pack-{}", short_id());
2650        let rule_names = extract_pack_rule_names(template_body.as_deref());
2651        let mut st = self.state.write();
2652        let acc = st.account_mut(account);
2653        let existing = acc.conformance_packs.get(&name);
2654        let pack = ConformancePack {
2655            name: name.clone(),
2656            arn: existing.map(|p| p.arn.clone()).unwrap_or(arn.clone()),
2657            id: existing.map(|p| p.id.clone()).unwrap_or(id),
2658            delivery_s3_bucket: body
2659                .get("DeliveryS3Bucket")
2660                .and_then(Value::as_str)
2661                .map(String::from),
2662            delivery_s3_key_prefix: body
2663                .get("DeliveryS3KeyPrefix")
2664                .and_then(Value::as_str)
2665                .map(String::from),
2666            input_parameters: body
2667                .get("ConformancePackInputParameters")
2668                .and_then(Value::as_array)
2669                .cloned()
2670                .unwrap_or_default(),
2671            template_body,
2672            template_s3_uri,
2673            template_ssm_document_details: body.get("TemplateSSMDocumentDetails").cloned(),
2674            last_update_requested_time: now,
2675            created_by: None,
2676            rule_names,
2677        };
2678        let out_arn = pack.arn.clone();
2679        acc.conformance_packs.insert(name, pack);
2680        Ok(AwsResponse::ok_json(
2681            json!({ "ConformancePackArn": out_arn }),
2682        ))
2683    }
2684
2685    fn describe_conformance_packs(
2686        &self,
2687        account: &str,
2688        body: &Value,
2689    ) -> Result<AwsResponse, AwsServiceError> {
2690        let names = string_list(body, "ConformancePackNames");
2691        let st = self.state.read();
2692        let list: Vec<Value> = st
2693            .account(account)
2694            .map(|a| {
2695                a.conformance_packs
2696                    .values()
2697                    .filter(|p| names.is_empty() || names.contains(&p.name))
2698                    .map(|p| {
2699                        json!({
2700                            "ConformancePackName": p.name,
2701                            "ConformancePackArn": p.arn,
2702                            "ConformancePackId": p.id,
2703                            "DeliveryS3Bucket": p.delivery_s3_bucket,
2704                            "DeliveryS3KeyPrefix": p.delivery_s3_key_prefix,
2705                            "ConformancePackInputParameters": p.input_parameters,
2706                            "LastUpdateRequestedTime": p.last_update_requested_time.timestamp() as f64,
2707                            "TemplateSSMDocumentDetails": p.template_ssm_document_details,
2708                        })
2709                    })
2710                    .collect()
2711            })
2712            .unwrap_or_default();
2713        if !names.is_empty() {
2714            for n in &names {
2715                if !st
2716                    .account(account)
2717                    .map(|a| a.conformance_packs.contains_key(n))
2718                    .unwrap_or(false)
2719                {
2720                    return Err(no_such(
2721                        "NoSuchConformancePackException",
2722                        format!("Cannot find conformance pack with name '{n}'."),
2723                    ));
2724                }
2725            }
2726        }
2727        Ok(paged_response(
2728            "ConformancePackDetails",
2729            list,
2730            body,
2731            "NextToken",
2732        ))
2733    }
2734
2735    fn delete_conformance_pack(
2736        &self,
2737        account: &str,
2738        body: &Value,
2739    ) -> Result<AwsResponse, AwsServiceError> {
2740        let name = require_str(body, "ConformancePackName")?;
2741        let mut st = self.state.write();
2742        let acc = st.account_mut(account);
2743        if acc.conformance_packs.remove(&name).is_none() {
2744            return Err(no_such(
2745                "NoSuchConformancePackException",
2746                format!("Cannot find conformance pack with name '{name}'."),
2747            ));
2748        }
2749        Ok(AwsResponse::ok_json(json!({})))
2750    }
2751
2752    fn describe_conformance_pack_status(
2753        &self,
2754        account: &str,
2755        body: &Value,
2756    ) -> Result<AwsResponse, AwsServiceError> {
2757        let names = string_list(body, "ConformancePackNames");
2758        let now = Utc::now().timestamp() as f64;
2759        let st = self.state.read();
2760        let list: Vec<Value> = st
2761            .account(account)
2762            .map(|a| {
2763                a.conformance_packs
2764                    .values()
2765                    .filter(|p| names.is_empty() || names.contains(&p.name))
2766                    .map(|p| {
2767                        json!({
2768                            "ConformancePackName": p.name,
2769                            "ConformancePackId": p.id,
2770                            "ConformancePackArn": p.arn,
2771                            "ConformancePackState": "CREATE_COMPLETE",
2772                            "StackArn": format!("arn:aws:cloudformation:us-east-1:{account}:stack/awsconfigconforms-{}/{}", p.name, short_id()),
2773                            "LastUpdateRequestedTime": now,
2774                            "LastUpdateCompletedTime": now,
2775                        })
2776                    })
2777                    .collect()
2778            })
2779            .unwrap_or_default();
2780        Ok(paged_response(
2781            "ConformancePackStatusDetails",
2782            list,
2783            body,
2784            "NextToken",
2785        ))
2786    }
2787
2788    /// Rule names that belong to a pack (its own declared rules, or all account
2789    /// rules when the template did not declare any parseable ones).
2790    fn pack_rule_names(acc: &AccountState, pack: &ConformancePack) -> Vec<String> {
2791        if pack.rule_names.is_empty() {
2792            acc.rules.keys().cloned().collect()
2793        } else {
2794            pack.rule_names
2795                .iter()
2796                .filter(|n| acc.rules.contains_key(*n))
2797                .cloned()
2798                .collect()
2799        }
2800    }
2801
2802    fn describe_conformance_pack_compliance(
2803        &self,
2804        account: &str,
2805        body: &Value,
2806    ) -> Result<AwsResponse, AwsServiceError> {
2807        let name = require_str(body, "ConformancePackName")?;
2808        let st = self.state.read();
2809        let acc = st.account(account).ok_or_else(|| {
2810            no_such(
2811                "NoSuchConformancePackException",
2812                "No such conformance pack.",
2813            )
2814        })?;
2815        let pack = acc.conformance_packs.get(&name).ok_or_else(|| {
2816            no_such(
2817                "NoSuchConformancePackException",
2818                format!("Cannot find conformance pack with name '{name}'."),
2819            )
2820        })?;
2821        let list: Vec<Value> = Self::pack_rule_names(acc, pack)
2822            .iter()
2823            .map(|rule| {
2824                let c = acc
2825                    .evaluations
2826                    .get(rule)
2827                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
2828                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
2829                json!({ "ConfigRuleName": rule, "ComplianceType": c, "Controls": [] })
2830            })
2831            .collect();
2832        let (page, next) = paginate(list, body);
2833        let mut out =
2834            json!({ "ConformancePackName": name, "ConformancePackRuleComplianceList": page });
2835        if let Some(t) = next {
2836            out["NextToken"] = json!(t);
2837        }
2838        Ok(AwsResponse::ok_json(out))
2839    }
2840
2841    fn get_conformance_pack_compliance_summary(
2842        &self,
2843        account: &str,
2844        body: &Value,
2845    ) -> Result<AwsResponse, AwsServiceError> {
2846        let names = string_list(body, "ConformancePackNames");
2847        let st = self.state.read();
2848        let acc = st.account(account);
2849        let list: Vec<Value> = acc
2850            .map(|a| {
2851                a.conformance_packs
2852                    .values()
2853                    .filter(|p| names.is_empty() || names.contains(&p.name))
2854                    .map(|p| {
2855                        let rules = Self::pack_rule_names(a, p);
2856                        let noncompliant = rules.iter().any(|r| {
2857                            a.evaluations.get(r).map(|m| m.values().any(|e| e.compliance_type == "NON_COMPLIANT")).unwrap_or(false)
2858                        });
2859                        json!({
2860                            "ConformancePackName": p.name,
2861                            "ConformancePackComplianceStatus": if noncompliant { "NON_COMPLIANT" } else { "COMPLIANT" },
2862                        })
2863                    })
2864                    .collect()
2865            })
2866            .unwrap_or_default();
2867        Ok(AwsResponse::ok_json(
2868            json!({ "ConformancePackComplianceSummaryList": list }),
2869        ))
2870    }
2871
2872    fn get_conformance_pack_compliance_details(
2873        &self,
2874        account: &str,
2875        body: &Value,
2876    ) -> Result<AwsResponse, AwsServiceError> {
2877        let name = require_str(body, "ConformancePackName")?;
2878        let st = self.state.read();
2879        let acc = st.account(account).ok_or_else(|| {
2880            no_such(
2881                "NoSuchConformancePackException",
2882                "No such conformance pack.",
2883            )
2884        })?;
2885        let pack = acc.conformance_packs.get(&name).ok_or_else(|| {
2886            no_such(
2887                "NoSuchConformancePackException",
2888                format!("Cannot find conformance pack with name '{name}'."),
2889            )
2890        })?;
2891        let mut results = Vec::new();
2892        for rule in Self::pack_rule_names(acc, pack) {
2893            if let Some(m) = acc.evaluations.get(&rule) {
2894                for r in m.values() {
2895                    results.push(json!({
2896                        "ConfigRuleInvokedTime": r.config_rule_invoked_time.timestamp() as f64,
2897                        "ResultRecordedTime": r.result_recorded_time.timestamp() as f64,
2898                        "ComplianceType": r.compliance_type,
2899                        "EvaluationResultIdentifier": {
2900                            "EvaluationResultQualifier": {
2901                                "ConfigRuleName": rule,
2902                                "ResourceType": r.resource_type,
2903                                "ResourceId": r.resource_id,
2904                            },
2905                            "OrderingTimestamp": r.ordering_timestamp.timestamp() as f64,
2906                        },
2907                    }));
2908                }
2909            }
2910        }
2911        let (page, next) = paginate(results, body);
2912        let mut out =
2913            json!({ "ConformancePackName": name, "ConformancePackRuleEvaluationResults": page });
2914        if let Some(t) = next {
2915            out["NextToken"] = json!(t);
2916        }
2917        Ok(AwsResponse::ok_json(out))
2918    }
2919
2920    fn list_conformance_pack_compliance_scores(
2921        &self,
2922        account: &str,
2923    ) -> Result<AwsResponse, AwsServiceError> {
2924        let st = self.state.read();
2925        let list: Vec<Value> = st
2926            .account(account)
2927            .map(|a| {
2928                a.conformance_packs
2929                    .values()
2930                    .map(|p| {
2931                        let rules = Self::pack_rule_names(a, p);
2932                        let total = rules.len().max(1);
2933                        let compliant = rules
2934                            .iter()
2935                            .filter(|r| {
2936                                a.evaluations
2937                                    .get(*r)
2938                                    .map(|m| {
2939                                        !m.values().any(|e| e.compliance_type == "NON_COMPLIANT")
2940                                    })
2941                                    .unwrap_or(true)
2942                            })
2943                            .count();
2944                        let score = (compliant as f64 / total as f64) * 100.0;
2945                        json!({
2946                            "Score": format!("{score:.2}"),
2947                            "ConformancePackName": p.name,
2948                            "LastUpdatedTime": p.last_update_requested_time.timestamp() as f64,
2949                        })
2950                    })
2951                    .collect()
2952            })
2953            .unwrap_or_default();
2954        Ok(AwsResponse::ok_json(
2955            json!({ "ConformancePackComplianceScores": list }),
2956        ))
2957    }
2958}
2959
2960// ─── Organization rules / packs ─────────────────────────────────────────────
2961
2962impl ConfigService {
2963    fn put_organization_config_rule(
2964        &self,
2965        account: &str,
2966        region: &str,
2967        body: &Value,
2968    ) -> Result<AwsResponse, AwsServiceError> {
2969        let name = require_str(body, "OrganizationConfigRuleName")?;
2970        let arn = format!(
2971            "arn:aws:config:{region}:{account}:organization-config-rule/{name}-{}",
2972            short_id()
2973        );
2974        let mut st = self.state.write();
2975        let acc = st.account_mut(account);
2976        let out_arn = acc
2977            .org_rules
2978            .get(&name)
2979            .map(|r| r.arn.clone())
2980            .unwrap_or(arn);
2981        acc.org_rules.insert(
2982            name.clone(),
2983            OrganizationConfigRule {
2984                name,
2985                arn: out_arn.clone(),
2986                managed_rule_metadata: body.get("OrganizationManagedRuleMetadata").cloned(),
2987                custom_rule_metadata: body.get("OrganizationCustomRuleMetadata").cloned(),
2988                custom_policy_rule_metadata: body
2989                    .get("OrganizationCustomPolicyRuleMetadata")
2990                    .cloned(),
2991                excluded_accounts: string_list(body, "ExcludedAccounts"),
2992                last_update_time: Utc::now(),
2993            },
2994        );
2995        Ok(AwsResponse::ok_json(
2996            json!({ "OrganizationConfigRuleArn": out_arn }),
2997        ))
2998    }
2999
3000    fn describe_organization_config_rules(
3001        &self,
3002        account: &str,
3003        body: &Value,
3004    ) -> Result<AwsResponse, AwsServiceError> {
3005        let names = string_list(body, "OrganizationConfigRuleNames");
3006        let st = self.state.read();
3007        let list: Vec<Value> = st
3008            .account(account)
3009            .map(|a| {
3010                a.org_rules
3011                    .values()
3012                    .filter(|r| names.is_empty() || names.contains(&r.name))
3013                    .map(|r| {
3014                        json!({
3015                            "OrganizationConfigRuleName": r.name,
3016                            "OrganizationConfigRuleArn": r.arn,
3017                            "OrganizationManagedRuleMetadata": r.managed_rule_metadata,
3018                            "OrganizationCustomRuleMetadata": r.custom_rule_metadata,
3019                            "OrganizationCustomPolicyRuleMetadata": r.custom_policy_rule_metadata,
3020                            "ExcludedAccounts": r.excluded_accounts,
3021                            "LastUpdateTime": r.last_update_time.timestamp() as f64,
3022                        })
3023                    })
3024                    .collect()
3025            })
3026            .unwrap_or_default();
3027        Ok(AwsResponse::ok_json(
3028            json!({ "OrganizationConfigRules": list }),
3029        ))
3030    }
3031
3032    fn delete_organization_config_rule(
3033        &self,
3034        account: &str,
3035        body: &Value,
3036    ) -> Result<AwsResponse, AwsServiceError> {
3037        let name = require_str(body, "OrganizationConfigRuleName")?;
3038        let mut st = self.state.write();
3039        let acc = st.account_mut(account);
3040        if acc.org_rules.remove(&name).is_none() {
3041            return Err(no_such(
3042                "NoSuchOrganizationConfigRuleException",
3043                format!("Cannot find organization config rule '{name}'."),
3044            ));
3045        }
3046        Ok(AwsResponse::ok_json(json!({})))
3047    }
3048
3049    fn describe_organization_config_rule_statuses(
3050        &self,
3051        account: &str,
3052        body: &Value,
3053    ) -> Result<AwsResponse, AwsServiceError> {
3054        let names = string_list(body, "OrganizationConfigRuleNames");
3055        let now = Utc::now().timestamp() as f64;
3056        let st = self.state.read();
3057        let list: Vec<Value> = st
3058            .account(account)
3059            .map(|a| {
3060                a.org_rules
3061                    .values()
3062                    .filter(|r| names.is_empty() || names.contains(&r.name))
3063                    .map(|r| {
3064                        json!({ "OrganizationConfigRuleName": r.name, "OrganizationRuleStatus": "CREATE_SUCCESSFUL", "LastUpdateTime": now })
3065                    })
3066                    .collect()
3067            })
3068            .unwrap_or_default();
3069        Ok(AwsResponse::ok_json(
3070            json!({ "OrganizationConfigRuleStatuses": list }),
3071        ))
3072    }
3073
3074    fn get_organization_config_rule_detailed_status(
3075        &self,
3076        account: &str,
3077        body: &Value,
3078    ) -> Result<AwsResponse, AwsServiceError> {
3079        let name = require_str(body, "OrganizationConfigRuleName")?;
3080        let st = self.state.read();
3081        if !st
3082            .account(account)
3083            .map(|a| a.org_rules.contains_key(&name))
3084            .unwrap_or(false)
3085        {
3086            return Err(no_such(
3087                "NoSuchOrganizationConfigRuleException",
3088                format!("Cannot find organization config rule '{name}'."),
3089            ));
3090        }
3091        Ok(AwsResponse::ok_json(
3092            json!({ "OrganizationConfigRuleDetailedStatus": [] }),
3093        ))
3094    }
3095
3096    fn get_organization_custom_rule_policy(
3097        &self,
3098        account: &str,
3099        body: &Value,
3100    ) -> Result<AwsResponse, AwsServiceError> {
3101        let name = require_str(body, "OrganizationConfigRuleName")?;
3102        let st = self.state.read();
3103        let policy = st
3104            .account(account)
3105            .and_then(|a| a.org_rules.get(&name))
3106            .and_then(|r| r.custom_policy_rule_metadata.as_ref())
3107            .and_then(|m| {
3108                m.get("PolicyText")
3109                    .and_then(Value::as_str)
3110                    .map(String::from)
3111            });
3112        Ok(AwsResponse::ok_json(json!({ "PolicyText": policy })))
3113    }
3114
3115    fn put_organization_conformance_pack(
3116        &self,
3117        account: &str,
3118        region: &str,
3119        body: &Value,
3120    ) -> Result<AwsResponse, AwsServiceError> {
3121        let name = require_str(body, "OrganizationConformancePackName")?;
3122        let arn = format!(
3123            "arn:aws:config:{region}:{account}:organization-conformance-pack/{name}-{}",
3124            short_id()
3125        );
3126        let mut st = self.state.write();
3127        let acc = st.account_mut(account);
3128        let out_arn = acc
3129            .org_conformance_packs
3130            .get(&name)
3131            .map(|p| p.arn.clone())
3132            .unwrap_or(arn);
3133        acc.org_conformance_packs.insert(
3134            name.clone(),
3135            OrganizationConformancePack {
3136                name,
3137                arn: out_arn.clone(),
3138                delivery_s3_bucket: body
3139                    .get("DeliveryS3Bucket")
3140                    .and_then(Value::as_str)
3141                    .map(String::from),
3142                delivery_s3_key_prefix: body
3143                    .get("DeliveryS3KeyPrefix")
3144                    .and_then(Value::as_str)
3145                    .map(String::from),
3146                input_parameters: body
3147                    .get("ConformancePackInputParameters")
3148                    .and_then(Value::as_array)
3149                    .cloned()
3150                    .unwrap_or_default(),
3151                template_body: body
3152                    .get("TemplateBody")
3153                    .and_then(Value::as_str)
3154                    .map(String::from),
3155                template_s3_uri: body
3156                    .get("TemplateS3Uri")
3157                    .and_then(Value::as_str)
3158                    .map(String::from),
3159                excluded_accounts: string_list(body, "ExcludedAccounts"),
3160                last_update_time: Utc::now(),
3161            },
3162        );
3163        Ok(AwsResponse::ok_json(
3164            json!({ "OrganizationConformancePackArn": out_arn }),
3165        ))
3166    }
3167
3168    fn describe_organization_conformance_packs(
3169        &self,
3170        account: &str,
3171        body: &Value,
3172    ) -> Result<AwsResponse, AwsServiceError> {
3173        let names = string_list(body, "OrganizationConformancePackNames");
3174        let st = self.state.read();
3175        let list: Vec<Value> = st
3176            .account(account)
3177            .map(|a| {
3178                a.org_conformance_packs
3179                    .values()
3180                    .filter(|p| names.is_empty() || names.contains(&p.name))
3181                    .map(|p| {
3182                        json!({
3183                            "OrganizationConformancePackName": p.name,
3184                            "OrganizationConformancePackArn": p.arn,
3185                            "DeliveryS3Bucket": p.delivery_s3_bucket,
3186                            "DeliveryS3KeyPrefix": p.delivery_s3_key_prefix,
3187                            "ConformancePackInputParameters": p.input_parameters,
3188                            "ExcludedAccounts": p.excluded_accounts,
3189                            "LastUpdateTime": p.last_update_time.timestamp() as f64,
3190                        })
3191                    })
3192                    .collect()
3193            })
3194            .unwrap_or_default();
3195        Ok(AwsResponse::ok_json(
3196            json!({ "OrganizationConformancePacks": list }),
3197        ))
3198    }
3199
3200    fn delete_organization_conformance_pack(
3201        &self,
3202        account: &str,
3203        body: &Value,
3204    ) -> Result<AwsResponse, AwsServiceError> {
3205        let name = require_str(body, "OrganizationConformancePackName")?;
3206        let mut st = self.state.write();
3207        let acc = st.account_mut(account);
3208        if acc.org_conformance_packs.remove(&name).is_none() {
3209            return Err(no_such(
3210                "NoSuchOrganizationConformancePackException",
3211                format!("Cannot find organization conformance pack '{name}'."),
3212            ));
3213        }
3214        Ok(AwsResponse::ok_json(json!({})))
3215    }
3216
3217    fn describe_organization_conformance_pack_statuses(
3218        &self,
3219        account: &str,
3220        body: &Value,
3221    ) -> Result<AwsResponse, AwsServiceError> {
3222        let names = string_list(body, "OrganizationConformancePackNames");
3223        let now = Utc::now().timestamp() as f64;
3224        let st = self.state.read();
3225        let list: Vec<Value> = st
3226            .account(account)
3227            .map(|a| {
3228                a.org_conformance_packs
3229                    .values()
3230                    .filter(|p| names.is_empty() || names.contains(&p.name))
3231                    .map(|p| json!({ "OrganizationConformancePackName": p.name, "Status": "CREATE_SUCCESSFUL", "LastUpdateTime": now }))
3232                    .collect()
3233            })
3234            .unwrap_or_default();
3235        Ok(AwsResponse::ok_json(
3236            json!({ "OrganizationConformancePackStatuses": list }),
3237        ))
3238    }
3239
3240    fn get_organization_conformance_pack_detailed_status(
3241        &self,
3242        account: &str,
3243        body: &Value,
3244    ) -> Result<AwsResponse, AwsServiceError> {
3245        let name = require_str(body, "OrganizationConformancePackName")?;
3246        let st = self.state.read();
3247        if !st
3248            .account(account)
3249            .map(|a| a.org_conformance_packs.contains_key(&name))
3250            .unwrap_or(false)
3251        {
3252            return Err(no_such(
3253                "NoSuchOrganizationConformancePackException",
3254                format!("Cannot find organization conformance pack '{name}'."),
3255            ));
3256        }
3257        Ok(AwsResponse::ok_json(
3258            json!({ "OrganizationConformancePackDetailedStatuses": [] }),
3259        ))
3260    }
3261}
3262
3263// ─── Aggregators ────────────────────────────────────────────────────────────
3264
3265impl ConfigService {
3266    fn put_configuration_aggregator(
3267        &self,
3268        account: &str,
3269        region: &str,
3270        body: &Value,
3271    ) -> Result<AwsResponse, AwsServiceError> {
3272        let name = require_str(body, "ConfigurationAggregatorName")?;
3273        let now = Utc::now();
3274        let arn = format!(
3275            "arn:aws:config:{region}:{account}:config-aggregator/config-aggregator-{}",
3276            short_id()
3277        );
3278        let mut st = self.state.write();
3279        let acc = st.account_mut(account);
3280        let existing = acc.aggregators.get(&name);
3281        let agg = ConfigurationAggregator {
3282            name: name.clone(),
3283            arn: existing.map(|a| a.arn.clone()).unwrap_or(arn),
3284            account_aggregation_sources: body
3285                .get("AccountAggregationSources")
3286                .and_then(Value::as_array)
3287                .cloned()
3288                .unwrap_or_default(),
3289            organization_aggregation_source: body.get("OrganizationAggregationSource").cloned(),
3290            creation_time: existing.map(|a| a.creation_time).unwrap_or(now),
3291            last_updated_time: now,
3292            created_by: None,
3293        };
3294        let out = Self::aggregator_json(&agg);
3295        acc.aggregators.insert(name, agg);
3296        Ok(AwsResponse::ok_json(
3297            json!({ "ConfigurationAggregator": out }),
3298        ))
3299    }
3300
3301    fn aggregator_json(a: &ConfigurationAggregator) -> Value {
3302        json!({
3303            "ConfigurationAggregatorName": a.name,
3304            "ConfigurationAggregatorArn": a.arn,
3305            "AccountAggregationSources": a.account_aggregation_sources,
3306            "OrganizationAggregationSource": a.organization_aggregation_source,
3307            "CreationTime": a.creation_time.timestamp() as f64,
3308            "LastUpdatedTime": a.last_updated_time.timestamp() as f64,
3309        })
3310    }
3311
3312    fn describe_configuration_aggregators(
3313        &self,
3314        account: &str,
3315        body: &Value,
3316    ) -> Result<AwsResponse, AwsServiceError> {
3317        let names = string_list(body, "ConfigurationAggregatorNames");
3318        let st = self.state.read();
3319        let list: Vec<Value> = st
3320            .account(account)
3321            .map(|a| {
3322                a.aggregators
3323                    .values()
3324                    .filter(|g| names.is_empty() || names.contains(&g.name))
3325                    .map(Self::aggregator_json)
3326                    .collect()
3327            })
3328            .unwrap_or_default();
3329        if !names.is_empty() {
3330            for n in &names {
3331                if !st
3332                    .account(account)
3333                    .map(|a| a.aggregators.contains_key(n))
3334                    .unwrap_or(false)
3335                {
3336                    return Err(no_such(
3337                        "NoSuchConfigurationAggregatorException",
3338                        format!("The configuration aggregator '{n}' does not exist."),
3339                    ));
3340                }
3341            }
3342        }
3343        Ok(paged_response(
3344            "ConfigurationAggregators",
3345            list,
3346            body,
3347            "NextToken",
3348        ))
3349    }
3350
3351    fn delete_configuration_aggregator(
3352        &self,
3353        account: &str,
3354        body: &Value,
3355    ) -> Result<AwsResponse, AwsServiceError> {
3356        let name = require_str(body, "ConfigurationAggregatorName")?;
3357        let mut st = self.state.write();
3358        let acc = st.account_mut(account);
3359        if acc.aggregators.remove(&name).is_none() {
3360            return Err(no_such(
3361                "NoSuchConfigurationAggregatorException",
3362                format!("The configuration aggregator '{name}' does not exist."),
3363            ));
3364        }
3365        Ok(AwsResponse::ok_json(json!({})))
3366    }
3367
3368    fn describe_configuration_aggregator_sources_status(
3369        &self,
3370        account: &str,
3371        body: &Value,
3372    ) -> Result<AwsResponse, AwsServiceError> {
3373        let name = require_str(body, "ConfigurationAggregatorName")?;
3374        let now = Utc::now().timestamp() as f64;
3375        let st = self.state.read();
3376        let agg = st.account(account).and_then(|a| a.aggregators.get(&name));
3377        let Some(agg) = agg else {
3378            return Err(no_such(
3379                "NoSuchConfigurationAggregatorException",
3380                format!("The configuration aggregator '{name}' does not exist."),
3381            ));
3382        };
3383        let mut list = Vec::new();
3384        for src in &agg.account_aggregation_sources {
3385            let region = src
3386                .get("AwsRegions")
3387                .and_then(Value::as_array)
3388                .and_then(|a| a.first())
3389                .and_then(Value::as_str)
3390                .unwrap_or("us-east-1");
3391            let accounts = src
3392                .get("AccountIds")
3393                .and_then(Value::as_array)
3394                .cloned()
3395                .unwrap_or_default();
3396            for acct in accounts {
3397                list.push(json!({
3398                    "SourceId": acct,
3399                    "SourceType": "ACCOUNT",
3400                    "AwsRegion": region,
3401                    "LastUpdateStatus": "SUCCEEDED",
3402                    "LastUpdateTime": now,
3403                }));
3404            }
3405        }
3406        Ok(AwsResponse::ok_json(
3407            json!({ "AggregatedSourceStatusList": list }),
3408        ))
3409    }
3410
3411    fn put_aggregation_authorization(
3412        &self,
3413        account: &str,
3414        region: &str,
3415        body: &Value,
3416    ) -> Result<AwsResponse, AwsServiceError> {
3417        let authorized_account = require_str(body, "AuthorizedAccountId")?;
3418        let authorized_region = require_str(body, "AuthorizedAwsRegion")?;
3419        let now = Utc::now();
3420        let arn = format!("arn:aws:config:{region}:{account}:aggregation-authorization/{authorized_account}/{authorized_region}");
3421        let key = format!("{authorized_account}\u{1}{authorized_region}");
3422        let auth = AggregationAuthorization {
3423            arn: arn.clone(),
3424            authorized_account_id: authorized_account,
3425            authorized_aws_region: authorized_region,
3426            creation_time: now,
3427        };
3428        let out = Self::auth_json(&auth);
3429        let mut st = self.state.write();
3430        st.account_mut(account)
3431            .aggregation_authorizations
3432            .insert(key, auth);
3433        let _ = out;
3434        Ok(AwsResponse::ok_json(
3435            json!({ "AggregationAuthorization": Self::auth_json_by_arn(&arn) }),
3436        ))
3437    }
3438
3439    fn auth_json(a: &AggregationAuthorization) -> Value {
3440        json!({
3441            "AggregationAuthorizationArn": a.arn,
3442            "AuthorizedAccountId": a.authorized_account_id,
3443            "AuthorizedAwsRegion": a.authorized_aws_region,
3444            "CreationTime": a.creation_time.timestamp() as f64,
3445        })
3446    }
3447
3448    fn auth_json_by_arn(arn: &str) -> Value {
3449        json!({ "AggregationAuthorizationArn": arn })
3450    }
3451
3452    fn describe_aggregation_authorizations(
3453        &self,
3454        account: &str,
3455        body: &Value,
3456    ) -> Result<AwsResponse, AwsServiceError> {
3457        let st = self.state.read();
3458        let list: Vec<Value> = st
3459            .account(account)
3460            .map(|a| {
3461                a.aggregation_authorizations
3462                    .values()
3463                    .map(Self::auth_json)
3464                    .collect()
3465            })
3466            .unwrap_or_default();
3467        Ok(paged_response(
3468            "AggregationAuthorizations",
3469            list,
3470            body,
3471            "NextToken",
3472        ))
3473    }
3474
3475    fn delete_aggregation_authorization(
3476        &self,
3477        account: &str,
3478        body: &Value,
3479    ) -> Result<AwsResponse, AwsServiceError> {
3480        let authorized_account = require_str(body, "AuthorizedAccountId")?;
3481        let authorized_region = require_str(body, "AuthorizedAwsRegion")?;
3482        let key = format!("{authorized_account}\u{1}{authorized_region}");
3483        let mut st = self.state.write();
3484        st.account_mut(account)
3485            .aggregation_authorizations
3486            .remove(&key);
3487        Ok(AwsResponse::ok_json(json!({})))
3488    }
3489
3490    /// Aggregators aggregate over the same recorded items (single-account,
3491    /// single-region deployment) — real aggregation across the account's own
3492    /// recorded state.
3493    fn batch_get_aggregate_resource_config(
3494        &self,
3495        account: &str,
3496        body: &Value,
3497    ) -> Result<AwsResponse, AwsServiceError> {
3498        let keys = body
3499            .get("ResourceIdentifiers")
3500            .and_then(Value::as_array)
3501            .cloned()
3502            .unwrap_or_default();
3503        let st = self.state.read();
3504        let acc = st.account(account);
3505        let mut items = Vec::new();
3506        let mut unprocessed = Vec::new();
3507        for k in keys {
3508            let rt = k
3509                .get("ResourceType")
3510                .and_then(Value::as_str)
3511                .unwrap_or_default();
3512            let rid = k
3513                .get("ResourceId")
3514                .and_then(Value::as_str)
3515                .unwrap_or_default();
3516            let key = resource_key(rt, rid);
3517            match acc
3518                .and_then(|a| a.config_items.get(&key))
3519                .and_then(|h| h.last())
3520            {
3521                Some(ci) => items.push(Self::config_item_json(ci)),
3522                None => unprocessed.push(k),
3523            }
3524        }
3525        Ok(AwsResponse::ok_json(
3526            json!({ "BaseConfigurationItems": items, "UnprocessedResourceIdentifiers": unprocessed }),
3527        ))
3528    }
3529
3530    fn get_aggregate_resource_config(
3531        &self,
3532        account: &str,
3533        body: &Value,
3534    ) -> Result<AwsResponse, AwsServiceError> {
3535        let ident = body
3536            .get("ResourceIdentifier")
3537            .cloned()
3538            .unwrap_or(Value::Null);
3539        let rt = ident
3540            .get("ResourceType")
3541            .and_then(Value::as_str)
3542            .unwrap_or_default();
3543        let rid = ident
3544            .get("ResourceId")
3545            .and_then(Value::as_str)
3546            .unwrap_or_default();
3547        let st = self.state.read();
3548        let key = resource_key(rt, rid);
3549        let ci = st
3550            .account(account)
3551            .and_then(|a| a.config_items.get(&key))
3552            .and_then(|h| h.last());
3553        match ci {
3554            Some(ci) => Ok(AwsResponse::ok_json(
3555                json!({ "ConfigurationItem": Self::config_item_json(ci) }),
3556            )),
3557            None => Err(no_such(
3558                "ResourceNotDiscoveredException",
3559                "Resource is not discovered by the aggregator.",
3560            )),
3561        }
3562    }
3563
3564    fn list_aggregate_discovered_resources(
3565        &self,
3566        account: &str,
3567        body: &Value,
3568    ) -> Result<AwsResponse, AwsServiceError> {
3569        let resource_type = require_str(body, "ResourceType")?;
3570        let st = self.state.read();
3571        let mut list = Vec::new();
3572        if let Some(acc) = st.account(account) {
3573            for (key, history) in &acc.config_items {
3574                let Some((rt, _)) = key.split_once('\u{1}') else {
3575                    continue;
3576                };
3577                if rt != resource_type {
3578                    continue;
3579                }
3580                let Some(ci) = history.last() else { continue };
3581                if ci.configuration_item_status.starts_with("ResourceDeleted") {
3582                    continue;
3583                }
3584                list.push(json!({
3585                    "SourceAccountId": account,
3586                    "SourceRegion": ci.aws_region,
3587                    "ResourceId": ci.resource_id,
3588                    "ResourceType": ci.resource_type,
3589                    "ResourceName": ci.resource_name,
3590                }));
3591            }
3592        }
3593        Ok(AwsResponse::ok_json(json!({ "ResourceIdentifiers": list })))
3594    }
3595
3596    fn get_aggregate_discovered_resource_counts(
3597        &self,
3598        account: &str,
3599        _body: &Value,
3600    ) -> Result<AwsResponse, AwsServiceError> {
3601        let st = self.state.read();
3602        let mut counts: std::collections::BTreeMap<String, i64> = std::collections::BTreeMap::new();
3603        if let Some(acc) = st.account(account) {
3604            for (key, history) in &acc.config_items {
3605                let Some((rt, _)) = key.split_once('\u{1}') else {
3606                    continue;
3607                };
3608                if history
3609                    .last()
3610                    .map(|ci| ci.configuration_item_status.starts_with("ResourceDeleted"))
3611                    .unwrap_or(true)
3612                {
3613                    continue;
3614                }
3615                *counts.entry(rt.to_string()).or_insert(0) += 1;
3616            }
3617        }
3618        let total: i64 = counts.values().sum();
3619        let list: Vec<Value> = counts
3620            .into_iter()
3621            .map(|(t, c)| json!({ "ResourceType": t, "Count": c }))
3622            .collect();
3623        Ok(AwsResponse::ok_json(
3624            json!({ "TotalDiscoveredResources": total, "GroupedResourceCounts": list }),
3625        ))
3626    }
3627
3628    fn describe_aggregate_compliance_by_config_rules(
3629        &self,
3630        account: &str,
3631        _body: &Value,
3632    ) -> Result<AwsResponse, AwsServiceError> {
3633        let st = self.state.read();
3634        let mut list = Vec::new();
3635        if let Some(acc) = st.account(account) {
3636            for rule in acc.rules.values() {
3637                let c = acc
3638                    .evaluations
3639                    .get(&rule.name)
3640                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
3641                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into());
3642                list.push(json!({
3643                    "ConfigRuleName": rule.name,
3644                    "Compliance": { "ComplianceType": c },
3645                    "AccountId": account,
3646                    "AwsRegion": "us-east-1",
3647                }));
3648            }
3649        }
3650        Ok(AwsResponse::ok_json(
3651            json!({ "AggregateComplianceByConfigRules": list }),
3652        ))
3653    }
3654
3655    fn describe_aggregate_compliance_by_conformance_packs(
3656        &self,
3657        account: &str,
3658        _body: &Value,
3659    ) -> Result<AwsResponse, AwsServiceError> {
3660        let st = self.state.read();
3661        let list: Vec<Value> = st
3662            .account(account)
3663            .map(|a| {
3664                a.conformance_packs
3665                    .values()
3666                    .map(|p| {
3667                        let rules = Self::pack_rule_names(a, p);
3668                        let noncompliant = rules.iter().any(|r| a.evaluations.get(r).map(|m| m.values().any(|e| e.compliance_type == "NON_COMPLIANT")).unwrap_or(false));
3669                        json!({
3670                            "ConformancePackName": p.name,
3671                            "Compliance": { "ComplianceType": if noncompliant { "NON_COMPLIANT" } else { "COMPLIANT" } },
3672                            "AccountId": account,
3673                            "AwsRegion": "us-east-1",
3674                        })
3675                    })
3676                    .collect()
3677            })
3678            .unwrap_or_default();
3679        Ok(AwsResponse::ok_json(
3680            json!({ "AggregateComplianceByConformancePacks": list }),
3681        ))
3682    }
3683
3684    fn get_aggregate_compliance_details_by_config_rule(
3685        &self,
3686        account: &str,
3687        body: &Value,
3688    ) -> Result<AwsResponse, AwsServiceError> {
3689        let name = require_str(body, "ConfigRuleName")?;
3690        let st = self.state.read();
3691        let results: Vec<Value> = st
3692            .account(account)
3693            .and_then(|a| a.evaluations.get(&name))
3694            .map(|m| {
3695                m.values()
3696                    .map(|r| {
3697                        json!({
3698                            "AccountId": account,
3699                            "AwsRegion": "us-east-1",
3700                            "ComplianceType": r.compliance_type,
3701                            "ResultRecordedTime": r.result_recorded_time.timestamp() as f64,
3702                            "ConfigRuleInvokedTime": r.config_rule_invoked_time.timestamp() as f64,
3703                            "Annotation": r.annotation,
3704                            "EvaluationResultIdentifier": {
3705                                "EvaluationResultQualifier": {
3706                                    "ConfigRuleName": name,
3707                                    "ResourceType": r.resource_type,
3708                                    "ResourceId": r.resource_id,
3709                                },
3710                                "OrderingTimestamp": r.ordering_timestamp.timestamp() as f64,
3711                            },
3712                        })
3713                    })
3714                    .collect()
3715            })
3716            .unwrap_or_default();
3717        Ok(AwsResponse::ok_json(
3718            json!({ "AggregateEvaluationResults": results }),
3719        ))
3720    }
3721
3722    fn get_aggregate_config_rule_compliance_summary(
3723        &self,
3724        account: &str,
3725        _body: &Value,
3726    ) -> Result<AwsResponse, AwsServiceError> {
3727        let st = self.state.read();
3728        let mut compliant = 0;
3729        let mut noncompliant = 0;
3730        if let Some(acc) = st.account(account) {
3731            for rule in acc.rules.values() {
3732                match acc
3733                    .evaluations
3734                    .get(&rule.name)
3735                    .map(|m| fold_compliance(m.values().map(|r| r.compliance_type.as_str())))
3736                    .unwrap_or_else(|| "INSUFFICIENT_DATA".into())
3737                    .as_str()
3738                {
3739                    "COMPLIANT" => compliant += 1,
3740                    "NON_COMPLIANT" => noncompliant += 1,
3741                    _ => {}
3742                }
3743            }
3744        }
3745        Ok(AwsResponse::ok_json(json!({
3746            "GroupByKey": "ACCOUNT_ID",
3747            "AggregateComplianceCounts": [{
3748                "GroupName": account,
3749                "ComplianceSummary": {
3750                    "CompliantResourceCount": { "CappedCount": compliant, "CapExceeded": false },
3751                    "NonCompliantResourceCount": { "CappedCount": noncompliant, "CapExceeded": false },
3752                    "ComplianceSummaryTimestamp": Utc::now().timestamp() as f64,
3753                }
3754            }]
3755        })))
3756    }
3757
3758    fn get_aggregate_conformance_pack_compliance_summary(
3759        &self,
3760        account: &str,
3761        _body: &Value,
3762    ) -> Result<AwsResponse, AwsServiceError> {
3763        let st = self.state.read();
3764        let mut compliant = 0;
3765        let mut noncompliant = 0;
3766        if let Some(acc) = st.account(account) {
3767            for p in acc.conformance_packs.values() {
3768                let rules = Self::pack_rule_names(acc, p);
3769                let nc = rules.iter().any(|r| {
3770                    acc.evaluations
3771                        .get(r)
3772                        .map(|m| m.values().any(|e| e.compliance_type == "NON_COMPLIANT"))
3773                        .unwrap_or(false)
3774                });
3775                if nc {
3776                    noncompliant += 1;
3777                } else {
3778                    compliant += 1;
3779                }
3780            }
3781        }
3782        Ok(AwsResponse::ok_json(json!({
3783            "GroupByKey": "ACCOUNT_ID",
3784            "AggregateConformancePackComplianceSummaries": [{
3785                "GroupName": account,
3786                "ComplianceSummary": {
3787                    "CompliantConformancePackCount": compliant,
3788                    "NonCompliantConformancePackCount": noncompliant,
3789                }
3790            }]
3791        })))
3792    }
3793}
3794
3795// ─── Retention / stored queries / resource evaluations / select / tags ──────
3796
3797impl ConfigService {
3798    fn put_retention_configuration(
3799        &self,
3800        account: &str,
3801        body: &Value,
3802    ) -> Result<AwsResponse, AwsServiceError> {
3803        let days = body
3804            .get("RetentionPeriodInDays")
3805            .and_then(Value::as_i64)
3806            .ok_or_else(|| invalid("RetentionPeriodInDays is required"))?;
3807        if !(30..=2557).contains(&days) {
3808            return Err(invalid("RetentionPeriodInDays must be between 30 and 2557"));
3809        }
3810        let mut st = self.state.write();
3811        st.account_mut(account).retention_configurations.insert(
3812            "default".into(),
3813            RetentionConfiguration {
3814                name: "default".into(),
3815                retention_period_in_days: days,
3816            },
3817        );
3818        Ok(AwsResponse::ok_json(
3819            json!({ "RetentionConfiguration": { "Name": "default", "RetentionPeriodInDays": days } }),
3820        ))
3821    }
3822
3823    fn describe_retention_configurations(
3824        &self,
3825        account: &str,
3826        body: &Value,
3827    ) -> Result<AwsResponse, AwsServiceError> {
3828        let st = self.state.read();
3829        let list: Vec<Value> = st
3830            .account(account)
3831            .map(|a| a.retention_configurations.values().map(|r| json!({ "Name": r.name, "RetentionPeriodInDays": r.retention_period_in_days })).collect())
3832            .unwrap_or_default();
3833        Ok(paged_response(
3834            "RetentionConfigurations",
3835            list,
3836            body,
3837            "NextToken",
3838        ))
3839    }
3840
3841    fn delete_retention_configuration(
3842        &self,
3843        account: &str,
3844        body: &Value,
3845    ) -> Result<AwsResponse, AwsServiceError> {
3846        let name = require_str(body, "RetentionConfigurationName")?;
3847        let mut st = self.state.write();
3848        let acc = st.account_mut(account);
3849        if acc.retention_configurations.remove(&name).is_none() {
3850            return Err(no_such(
3851                "NoSuchRetentionConfigurationException",
3852                format!("Cannot find retention configuration with the specified name '{name}'."),
3853            ));
3854        }
3855        Ok(AwsResponse::ok_json(json!({})))
3856    }
3857
3858    fn put_stored_query(
3859        &self,
3860        account: &str,
3861        region: &str,
3862        body: &Value,
3863    ) -> Result<AwsResponse, AwsServiceError> {
3864        let q = body
3865            .get("StoredQuery")
3866            .cloned()
3867            .ok_or_else(|| invalid("StoredQuery is required"))?;
3868        let name = q
3869            .get("QueryName")
3870            .and_then(Value::as_str)
3871            .ok_or_else(|| invalid("QueryName is required"))?
3872            .to_string();
3873        let mut st = self.state.write();
3874        let acc = st.account_mut(account);
3875        let existing = acc.stored_queries.get(&name);
3876        let id = existing
3877            .map(|s| s.id.clone())
3878            .unwrap_or_else(|| Uuid::new_v4().to_string());
3879        let arn = existing.map(|s| s.arn.clone()).unwrap_or_else(|| {
3880            format!("arn:aws:config:{region}:{account}:stored-query/{name}/{id}")
3881        });
3882        acc.stored_queries.insert(
3883            name.clone(),
3884            StoredQuery {
3885                id: id.clone(),
3886                name,
3887                arn: arn.clone(),
3888                description: q
3889                    .get("Description")
3890                    .and_then(Value::as_str)
3891                    .map(String::from),
3892                expression: q
3893                    .get("Expression")
3894                    .and_then(Value::as_str)
3895                    .map(String::from),
3896            },
3897        );
3898        let _ = id;
3899        Ok(AwsResponse::ok_json(json!({ "QueryArn": arn })))
3900    }
3901
3902    fn get_stored_query(
3903        &self,
3904        account: &str,
3905        body: &Value,
3906    ) -> Result<AwsResponse, AwsServiceError> {
3907        let name = require_str(body, "QueryName")?;
3908        let st = self.state.read();
3909        let q = st
3910            .account(account)
3911            .and_then(|a| a.stored_queries.get(&name));
3912        let Some(q) = q else {
3913            return Err(no_such(
3914                "ResourceNotFoundException",
3915                format!("The stored query '{name}' does not exist."),
3916            ));
3917        };
3918        Ok(AwsResponse::ok_json(json!({
3919            "StoredQuery": {
3920                "QueryId": q.id,
3921                "QueryArn": q.arn,
3922                "QueryName": q.name,
3923                "Description": q.description,
3924                "Expression": q.expression,
3925            }
3926        })))
3927    }
3928
3929    fn delete_stored_query(
3930        &self,
3931        account: &str,
3932        body: &Value,
3933    ) -> Result<AwsResponse, AwsServiceError> {
3934        let name = require_str(body, "QueryName")?;
3935        let mut st = self.state.write();
3936        let acc = st.account_mut(account);
3937        if acc.stored_queries.remove(&name).is_none() {
3938            return Err(no_such(
3939                "ResourceNotFoundException",
3940                format!("The stored query '{name}' does not exist."),
3941            ));
3942        }
3943        Ok(AwsResponse::ok_json(json!({})))
3944    }
3945
3946    fn list_stored_queries(
3947        &self,
3948        account: &str,
3949        body: &Value,
3950    ) -> Result<AwsResponse, AwsServiceError> {
3951        let st = self.state.read();
3952        let list: Vec<Value> = st
3953            .account(account)
3954            .map(|a| a.stored_queries.values().map(|q| json!({ "QueryId": q.id, "QueryArn": q.arn, "QueryName": q.name, "Description": q.description })).collect())
3955            .unwrap_or_default();
3956        Ok(paged_response(
3957            "StoredQueryMetadata",
3958            list,
3959            body,
3960            "NextToken",
3961        ))
3962    }
3963
3964    fn start_resource_evaluation(
3965        &self,
3966        account: &str,
3967        body: &Value,
3968    ) -> Result<AwsResponse, AwsServiceError> {
3969        let mode = body
3970            .get("EvaluationMode")
3971            .and_then(Value::as_str)
3972            .unwrap_or("DETECTIVE")
3973            .to_string();
3974        let details = body.get("ResourceDetails").cloned();
3975        // Evaluate the supplied resource configuration against the account's
3976        // managed rules for real, rather than storing a canned COMPLIANT.
3977        let (rtype, rid, config_str) = details
3978            .as_ref()
3979            .map(|d| {
3980                let rt = d
3981                    .get("ResourceType")
3982                    .and_then(Value::as_str)
3983                    .unwrap_or_default()
3984                    .to_string();
3985                let ri = d
3986                    .get("ResourceId")
3987                    .and_then(Value::as_str)
3988                    .unwrap_or_default()
3989                    .to_string();
3990                // ResourceConfiguration is a JSON document string in the AWS
3991                // API; tolerate a raw object too.
3992                let cfg = match d.get("ResourceConfiguration") {
3993                    Some(Value::String(s)) => s.clone(),
3994                    Some(v) => v.to_string(),
3995                    None => "null".to_string(),
3996                };
3997                (rt, ri, cfg)
3998            })
3999            .unwrap_or_default();
4000        let id = Uuid::new_v4().to_string();
4001        let mut st = self.state.write();
4002        let acc = st.account_mut(account);
4003        let evaluation_results = if rtype.is_empty() || rid.is_empty() {
4004            Vec::new()
4005        } else {
4006            validate::evaluate_proactive(acc, &rtype, &rid, &config_str)
4007        };
4008        acc.resource_evaluations.insert(
4009            id.clone(),
4010            ResourceEvaluation {
4011                resource_evaluation_id: id.clone(),
4012                evaluation_mode: mode,
4013                time_stamp: Utc::now(),
4014                status: "SUCCEEDED".into(),
4015                resource_details: details,
4016                evaluation_context: body.get("EvaluationContext").cloned(),
4017                evaluation_results,
4018            },
4019        );
4020        Ok(AwsResponse::ok_json(json!({ "ResourceEvaluationId": id })))
4021    }
4022
4023    fn get_resource_evaluation_summary(
4024        &self,
4025        account: &str,
4026        body: &Value,
4027    ) -> Result<AwsResponse, AwsServiceError> {
4028        let id = require_str(body, "ResourceEvaluationId")?;
4029        let st = self.state.read();
4030        let ev = st
4031            .account(account)
4032            .and_then(|a| a.resource_evaluations.get(&id));
4033        let Some(ev) = ev else {
4034            return Err(no_such(
4035                "ResourceNotFoundException",
4036                format!("ResourceEvaluationId '{id}' does not exist."),
4037            ));
4038        };
4039        let resource_type = ev
4040            .resource_details
4041            .as_ref()
4042            .and_then(|d| d.get("ResourceType"))
4043            .cloned()
4044            .unwrap_or(Value::Null);
4045        let resource_id = ev
4046            .resource_details
4047            .as_ref()
4048            .and_then(|d| d.get("ResourceId"))
4049            .cloned()
4050            .unwrap_or(Value::Null);
4051        // Real compliance folded from the recorded per-rule outcomes. No
4052        // applicable managed rule -> INSUFFICIENT_DATA, never a canned pass.
4053        let compliance = if ev.evaluation_results.is_empty() {
4054            "INSUFFICIENT_DATA".to_string()
4055        } else {
4056            fold_compliance(
4057                ev.evaluation_results
4058                    .iter()
4059                    .map(|r| r.compliance_type.as_str()),
4060            )
4061        };
4062        Ok(AwsResponse::ok_json(json!({
4063            "ResourceEvaluationId": ev.resource_evaluation_id,
4064            "EvaluationMode": ev.evaluation_mode,
4065            "EvaluationStatus": { "Status": ev.status },
4066            "EvaluationStartTimestamp": ev.time_stamp.timestamp() as f64,
4067            "Compliance": compliance,
4068            "ResourceDetails": { "ResourceId": resource_id, "ResourceType": resource_type },
4069        })))
4070    }
4071
4072    fn list_resource_evaluations(
4073        &self,
4074        account: &str,
4075        body: &Value,
4076    ) -> Result<AwsResponse, AwsServiceError> {
4077        let st = self.state.read();
4078        let list: Vec<Value> = st
4079            .account(account)
4080            .map(|a| {
4081                a.resource_evaluations
4082                    .values()
4083                    .map(|e| json!({ "ResourceEvaluationId": e.resource_evaluation_id, "EvaluationMode": e.evaluation_mode, "EvaluationStartTimestamp": e.time_stamp.timestamp() as f64 }))
4084                    .collect()
4085            })
4086            .unwrap_or_default();
4087        Ok(paged_response(
4088            "ResourceEvaluations",
4089            list,
4090            body,
4091            "NextToken",
4092        ))
4093    }
4094
4095    fn select_resource_config(
4096        &self,
4097        account: &str,
4098        body: &Value,
4099        aggregate: bool,
4100    ) -> Result<AwsResponse, AwsServiceError> {
4101        let expr = require_str(body, "Expression")?;
4102        let st = self.state.read();
4103        let empty = AccountState::default();
4104        let acc = st.account(account).unwrap_or(&empty);
4105        let rows = validate::run_select(&expr, acc)
4106            .map_err(|e| invalid(format!("Invalid SelectResourceConfig expression: {e}")))?;
4107        let results: Vec<Value> = rows.iter().map(|r| json!(r.to_string())).collect();
4108        let (page, next) = paginate(results, body);
4109        let mut out = json!({
4110            "Results": page,
4111            "QueryInfo": { "SelectFields": select_fields(&expr) },
4112        });
4113        if let Some(t) = next {
4114            out["NextToken"] = json!(t);
4115        }
4116        if aggregate {
4117            let _ = out.as_object_mut();
4118        }
4119        Ok(AwsResponse::ok_json(out))
4120    }
4121
4122    fn tag_resource(&self, account: &str, body: &Value) -> Result<AwsResponse, AwsServiceError> {
4123        let arn = require_str(body, "ResourceArn")?;
4124        let tags = body
4125            .get("Tags")
4126            .and_then(Value::as_array)
4127            .cloned()
4128            .unwrap_or_default();
4129        let mut st = self.state.write();
4130        let acc = st.account_mut(account);
4131        let entry = acc.tags.entry(arn).or_default();
4132        for t in tags {
4133            if let (Some(k), Some(v)) = (
4134                t.get("Key").and_then(Value::as_str),
4135                t.get("Value").and_then(Value::as_str),
4136            ) {
4137                entry.insert(k.to_string(), v.to_string());
4138            }
4139        }
4140        Ok(AwsResponse::ok_json(json!({})))
4141    }
4142
4143    fn untag_resource(&self, account: &str, body: &Value) -> Result<AwsResponse, AwsServiceError> {
4144        let arn = require_str(body, "ResourceArn")?;
4145        let keys = string_list(body, "TagKeys");
4146        let mut st = self.state.write();
4147        let acc = st.account_mut(account);
4148        if let Some(entry) = acc.tags.get_mut(&arn) {
4149            for k in keys {
4150                entry.remove(&k);
4151            }
4152        }
4153        Ok(AwsResponse::ok_json(json!({})))
4154    }
4155
4156    fn list_tags_for_resource(
4157        &self,
4158        account: &str,
4159        body: &Value,
4160    ) -> Result<AwsResponse, AwsServiceError> {
4161        let arn = require_str(body, "ResourceArn")?;
4162        let st = self.state.read();
4163        let list: Vec<Value> = st
4164            .account(account)
4165            .and_then(|a| a.tags.get(&arn))
4166            .map(|m| {
4167                m.iter()
4168                    .map(|(k, v)| json!({ "Key": k, "Value": v }))
4169                    .collect()
4170            })
4171            .unwrap_or_default();
4172        Ok(AwsResponse::ok_json(json!({ "Tags": list })))
4173    }
4174}
4175
4176// ─── Helpers ────────────────────────────────────────────────────────────────
4177
4178fn account_id(req: &AwsRequest) -> String {
4179    if req.account_id.is_empty() {
4180        "000000000000".to_string()
4181    } else {
4182        req.account_id.clone()
4183    }
4184}
4185
4186fn region(req: &AwsRequest) -> String {
4187    if req.region.is_empty() {
4188        "us-east-1".to_string()
4189    } else {
4190        req.region.clone()
4191    }
4192}
4193
4194fn short_id() -> String {
4195    fakecloud_core::ids::short_id(6)
4196}
4197
4198fn invalid(msg: impl Into<String>) -> AwsServiceError {
4199    AwsServiceError::aws_error(
4200        StatusCode::BAD_REQUEST,
4201        "InvalidParameterValueException",
4202        msg,
4203    )
4204}
4205
4206fn no_such(code: &str, msg: impl Into<String>) -> AwsServiceError {
4207    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, code, msg)
4208}
4209
4210fn require_str(body: &Value, field: &str) -> Result<String, AwsServiceError> {
4211    body.get(field)
4212        .and_then(Value::as_str)
4213        .filter(|s| !s.is_empty())
4214        .map(String::from)
4215        .ok_or_else(|| invalid(format!("{field} is required")))
4216}
4217
4218fn string_list(body: &Value, field: &str) -> Vec<String> {
4219    body.get(field)
4220        .and_then(Value::as_array)
4221        .map(|a| {
4222            a.iter()
4223                .filter_map(|v| v.as_str().map(String::from))
4224                .collect()
4225        })
4226        .unwrap_or_default()
4227}
4228
4229/// Read the page-size cap from a request body, tolerating the several field
4230/// spellings AWS Config uses across operations (`Limit`, `MaxResults`, and the
4231/// lowercase `limit` on the resource-config data-plane ops).
4232fn page_limit(body: &Value) -> Option<usize> {
4233    ["Limit", "MaxResults", "limit"]
4234        .iter()
4235        .find_map(|k| body.get(*k).and_then(Value::as_i64))
4236        .filter(|l| *l > 0)
4237        .map(|l| l as usize)
4238}
4239
4240/// Read the continuation token, tolerating both `NextToken` (control plane) and
4241/// `nextToken` (data plane) spellings.
4242fn page_token(body: &Value) -> Option<usize> {
4243    ["NextToken", "nextToken"]
4244        .iter()
4245        .find_map(|k| body.get(*k).and_then(Value::as_str))
4246        .filter(|s| !s.is_empty())
4247        .and_then(decode_page_token)
4248}
4249
4250/// Tokens are an opaque offset into the full ordered result list. They are
4251/// encoded so callers treat them as opaque; decoding a malformed token yields
4252/// `None` (the page then restarts from the beginning rather than erroring).
4253fn encode_page_token(offset: usize) -> String {
4254    format!("cfg:{offset}")
4255}
4256
4257fn decode_page_token(token: &str) -> Option<usize> {
4258    token.strip_prefix("cfg:").and_then(|n| n.parse().ok())
4259}
4260
4261/// Slice `items` to a single page given the request's `Limit`/`NextToken`.
4262/// Returns the page and the token for the next page (`None` when exhausted).
4263fn paginate(items: Vec<Value>, body: &Value) -> (Vec<Value>, Option<String>) {
4264    let total = items.len();
4265    let start = page_token(body).unwrap_or(0).min(total);
4266    let end = match page_limit(body) {
4267        Some(limit) => start.saturating_add(limit).min(total),
4268        None => total,
4269    };
4270    let next = if end < total {
4271        Some(encode_page_token(end))
4272    } else {
4273        None
4274    };
4275    (
4276        items.into_iter().skip(start).take(end - start).collect(),
4277        next,
4278    )
4279}
4280
4281/// Build a paginated list response: the list under `field`, plus a continuation
4282/// token under `token_field` (`NextToken` for the control plane, `nextToken`
4283/// for the data plane) when more items remain.
4284fn paged_response(field: &str, items: Vec<Value>, body: &Value, token_field: &str) -> AwsResponse {
4285    let (page, next) = paginate(items, body);
4286    let mut out = json!({ field: page });
4287    if let Some(t) = next {
4288        out[token_field] = json!(t);
4289    }
4290    AwsResponse::ok_json(out)
4291}
4292
4293/// Build the invoking event handed to a custom-Lambda config rule. The rule's
4294/// stored `InputParameters` are passed through verbatim as `ruleParameters` (a
4295/// JSON string), exactly as AWS Config does, defaulting to `"{}"` when the rule
4296/// declared none — a custom rule that reads its parameters must see them.
4297fn custom_rule_event(
4298    account: &str,
4299    rule_name: &str,
4300    input_parameters: Option<&str>,
4301    result_token: &str,
4302) -> Value {
4303    let invoking_event = json!({
4304        "awsAccountId": account,
4305        "configRuleName": rule_name,
4306        "messageType": "ScheduledNotification",
4307        "notificationCreationTime": Utc::now().to_rfc3339(),
4308    })
4309    .to_string();
4310    let rule_parameters = input_parameters
4311        .filter(|p| !p.is_empty())
4312        .map(String::from)
4313        .unwrap_or_else(|| "{}".to_string());
4314    json!({
4315        "invokingEvent": invoking_event,
4316        "ruleParameters": rule_parameters,
4317        "resultToken": result_token,
4318        "configRuleArn": format!("arn:aws:config:us-east-1:{account}:config-rule/{rule_name}"),
4319        "configRuleName": rule_name,
4320        "accountId": account,
4321    })
4322}
4323
4324/// Best-effort extraction of the SELECT field names for the `QueryInfo`.
4325fn select_fields(expr: &str) -> Vec<Value> {
4326    let lower = expr.to_ascii_lowercase();
4327    let Some(start) = lower.find("select ") else {
4328        return Vec::new();
4329    };
4330    let after = &expr[start + 7..];
4331    let end = after
4332        .to_ascii_lowercase()
4333        .find(" where ")
4334        .unwrap_or(after.len());
4335    after[..end]
4336        .split(',')
4337        .map(|s| json!({ "Name": s.trim() }))
4338        .collect()
4339}
4340
4341#[cfg(test)]
4342mod unit_tests {
4343    use super::*;
4344
4345    #[test]
4346    fn custom_rule_event_passes_stored_input_parameters() {
4347        let params = r#"{"maxAccessKeyAge":"90"}"#;
4348        let event = custom_rule_event("111122223333", "my-rule", Some(params), "my-rule#tok");
4349        // ruleParameters must carry the rule's stored InputParameters verbatim,
4350        // not a hardcoded "{}".
4351        assert_eq!(event["ruleParameters"], json!(params));
4352        assert_eq!(event["configRuleName"], json!("my-rule"));
4353        assert_eq!(event["accountId"], json!("111122223333"));
4354        assert_eq!(event["resultToken"], json!("my-rule#tok"));
4355    }
4356
4357    #[test]
4358    fn custom_rule_event_defaults_empty_parameters_to_empty_object() {
4359        let none = custom_rule_event("111122223333", "r", None, "r#t");
4360        assert_eq!(none["ruleParameters"], json!("{}"));
4361        let empty = custom_rule_event("111122223333", "r", Some(""), "r#t");
4362        assert_eq!(empty["ruleParameters"], json!("{}"));
4363    }
4364
4365    #[test]
4366    fn paginate_slices_and_emits_token() {
4367        let items: Vec<Value> = (0..5).map(|i| json!(i)).collect();
4368        let first_req = json!({ "Limit": 2 });
4369        let (page, next) = paginate(items.clone(), &first_req);
4370        assert_eq!(page, vec![json!(0), json!(1)]);
4371        let token = next.expect("more items remain -> token");
4372
4373        let second_req = json!({ "Limit": 2, "NextToken": token });
4374        let (page2, next2) = paginate(items.clone(), &second_req);
4375        assert_eq!(page2, vec![json!(2), json!(3)]);
4376        let token2 = next2.expect("still more");
4377
4378        let third_req = json!({ "Limit": 2, "NextToken": token2 });
4379        let (page3, next3) = paginate(items, &third_req);
4380        assert_eq!(page3, vec![json!(4)]);
4381        assert!(next3.is_none(), "last page has no token");
4382    }
4383
4384    #[test]
4385    fn paginate_no_limit_returns_all() {
4386        let items: Vec<Value> = (0..3).map(|i| json!(i)).collect();
4387        let (page, next) = paginate(items.clone(), &json!({}));
4388        assert_eq!(page, items);
4389        assert!(next.is_none());
4390    }
4391
4392    #[test]
4393    fn extract_pack_rule_names_parses_yaml_template() {
4394        let yaml = r#"
4395Resources:
4396  MyRule:
4397    Type: AWS::Config::ConfigRule
4398    Properties:
4399      ConfigRuleName: yaml-declared-rule
4400      Source:
4401        Owner: AWS
4402        SourceIdentifier: S3_BUCKET_VERSIONING_ENABLED
4403"#;
4404        let names = extract_pack_rule_names(Some(yaml));
4405        assert_eq!(names, vec!["yaml-declared-rule".to_string()]);
4406    }
4407}