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