Skip to main content

fakecloud_wafv2/service/
mod.rs

1//! WAF v2 JSON 1.1 service.
2
3use std::collections::BTreeMap;
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use base64::Engine;
8use chrono::Utc;
9use http::StatusCode;
10use parking_lot::RwLock;
11use serde_json::{json, Value};
12use uuid::Uuid;
13
14use fakecloud_aws::arn::Arn;
15use fakecloud_core::pagination::paginate;
16use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
17
18use crate::evaluator::RateLimiter;
19use crate::state::{
20    AccountState, ApiKey, IpSet, RegexPatternSet, RuleGroup, SharedWafv2State, Wafv2Accounts,
21    WebAcl,
22};
23
24const SUPPORTED_ACTIONS: &[&str] = &[
25    "AssociateWebACL",
26    "CheckCapacity",
27    "CreateAPIKey",
28    "CreateIPSet",
29    "CreateRegexPatternSet",
30    "CreateRuleGroup",
31    "CreateWebACL",
32    "DeleteAPIKey",
33    "DeleteFirewallManagerRuleGroups",
34    "DeleteIPSet",
35    "DeleteLoggingConfiguration",
36    "DeletePermissionPolicy",
37    "DeleteRegexPatternSet",
38    "DeleteRuleGroup",
39    "DeleteWebACL",
40    "DescribeAllManagedProducts",
41    "DescribeManagedProductsByVendor",
42    "DescribeManagedRuleGroup",
43    "DisassociateWebACL",
44    "GenerateMobileSdkReleaseUrl",
45    "GetDecryptedAPIKey",
46    "GetIPSet",
47    "GetLoggingConfiguration",
48    "GetManagedRuleSet",
49    "GetMobileSdkRelease",
50    "GetPermissionPolicy",
51    "GetRateBasedStatementManagedKeys",
52    "GetRegexPatternSet",
53    "GetRuleGroup",
54    "GetSampledRequests",
55    "GetTopPathStatisticsByTraffic",
56    "GetWebACL",
57    "GetWebACLForResource",
58    "ListAPIKeys",
59    "ListAvailableManagedRuleGroups",
60    "ListAvailableManagedRuleGroupVersions",
61    "ListIPSets",
62    "ListLoggingConfigurations",
63    "ListManagedRuleSets",
64    "ListMobileSdkReleases",
65    "ListRegexPatternSets",
66    "ListResourcesForWebACL",
67    "ListRuleGroups",
68    "ListTagsForResource",
69    "ListWebACLs",
70    "PutLoggingConfiguration",
71    "PutManagedRuleSetVersions",
72    "PutPermissionPolicy",
73    "TagResource",
74    "UntagResource",
75    "UpdateIPSet",
76    "UpdateManagedRuleSetVersionExpiryDate",
77    "UpdateRegexPatternSet",
78    "UpdateRuleGroup",
79    "UpdateWebACL",
80];
81
82pub struct Wafv2Service {
83    state: SharedWafv2State,
84    rate_limiter: Arc<RateLimiter>,
85}
86
87mod api_keys;
88mod capacity;
89mod ip_sets;
90mod logging;
91mod mobile_sdk;
92mod permission_policy;
93mod regex_pattern_sets;
94mod rule_groups;
95mod sampled_requests;
96mod tags;
97mod web_acls;
98
99impl Wafv2Service {
100    pub fn new(state: SharedWafv2State) -> Self {
101        Self::with_rate_limiter(state, Arc::new(RateLimiter::new()))
102    }
103
104    /// Construct with an externally-owned rate limiter so the server can
105    /// share a single `RateLimiter` between this service and the admin
106    /// `/_fakecloud/wafv2/evaluate` endpoint.
107    pub fn with_rate_limiter(state: SharedWafv2State, rate_limiter: Arc<RateLimiter>) -> Self {
108        Self {
109            state,
110            rate_limiter,
111        }
112    }
113
114    pub fn shared_state(&self) -> SharedWafv2State {
115        Arc::clone(&self.state)
116    }
117
118    /// Shared, in-process [`RateLimiter`] used by `RateBasedStatement`
119    /// evaluation. Every dataplane caller (ALB, API Gateway, CloudFront) and
120    /// the test admin endpoint must use this same instance so all WAFv2
121    /// evaluations through this server share their counters.
122    pub fn rate_limiter(&self) -> Arc<RateLimiter> {
123        Arc::clone(&self.rate_limiter)
124    }
125}
126
127impl Default for Wafv2Service {
128    fn default() -> Self {
129        Self::new(Arc::new(RwLock::new(Wafv2Accounts::new())))
130    }
131}
132
133#[async_trait]
134impl AwsService for Wafv2Service {
135    fn service_name(&self) -> &str {
136        "wafv2"
137    }
138
139    fn supported_actions(&self) -> &[&str] {
140        SUPPORTED_ACTIONS
141    }
142
143    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
144        match req.action.as_str() {
145            "CreateWebACL" => self.create_web_acl(&req),
146            "GetWebACL" => self.get_web_acl(&req),
147            "ListWebACLs" => self.list_web_acls(&req),
148            "UpdateWebACL" => self.update_web_acl(&req),
149            "DeleteWebACL" => self.delete_web_acl(&req),
150            "CreateRuleGroup" => self.create_rule_group(&req),
151            "GetRuleGroup" => self.get_rule_group(&req),
152            "ListRuleGroups" => self.list_rule_groups(&req),
153            "UpdateRuleGroup" => self.update_rule_group(&req),
154            "DeleteRuleGroup" => self.delete_rule_group(&req),
155            "CreateIPSet" => self.create_ip_set(&req),
156            "GetIPSet" => self.get_ip_set(&req),
157            "ListIPSets" => self.list_ip_sets(&req),
158            "UpdateIPSet" => self.update_ip_set(&req),
159            "DeleteIPSet" => self.delete_ip_set(&req),
160            "CreateRegexPatternSet" => self.create_regex_pattern_set(&req),
161            "GetRegexPatternSet" => self.get_regex_pattern_set(&req),
162            "ListRegexPatternSets" => self.list_regex_pattern_sets(&req),
163            "UpdateRegexPatternSet" => self.update_regex_pattern_set(&req),
164            "DeleteRegexPatternSet" => self.delete_regex_pattern_set(&req),
165            "AssociateWebACL" => self.associate_web_acl(&req),
166            "DisassociateWebACL" => self.disassociate_web_acl(&req),
167            "GetWebACLForResource" => self.get_web_acl_for_resource(&req),
168            "ListResourcesForWebACL" => self.list_resources_for_web_acl(&req),
169            "PutLoggingConfiguration" => self.put_logging_configuration(&req),
170            "GetLoggingConfiguration" => self.get_logging_configuration(&req),
171            "DeleteLoggingConfiguration" => self.delete_logging_configuration(&req),
172            "ListLoggingConfigurations" => self.list_logging_configurations(&req),
173            "PutPermissionPolicy" => self.put_permission_policy(&req),
174            "GetPermissionPolicy" => self.get_permission_policy(&req),
175            "DeletePermissionPolicy" => self.delete_permission_policy(&req),
176            "TagResource" => self.tag_resource(&req),
177            "UntagResource" => self.untag_resource(&req),
178            "ListTagsForResource" => self.list_tags_for_resource(&req),
179            "CreateAPIKey" => self.create_api_key(&req),
180            "DeleteAPIKey" => self.delete_api_key(&req),
181            "GetDecryptedAPIKey" => self.get_decrypted_api_key(&req),
182            "ListAPIKeys" => self.list_api_keys(&req),
183            "DescribeAllManagedProducts" => self.describe_all_managed_products(&req),
184            "DescribeManagedProductsByVendor" => self.describe_managed_products_by_vendor(&req),
185            "DescribeManagedRuleGroup" => self.describe_managed_rule_group(&req),
186            "GetManagedRuleSet" => self.get_managed_rule_set(&req),
187            "ListAvailableManagedRuleGroups" => self.list_available_managed_rule_groups(&req),
188            "ListAvailableManagedRuleGroupVersions" => {
189                self.list_available_managed_rule_group_versions(&req)
190            }
191            "ListManagedRuleSets" => self.list_managed_rule_sets(&req),
192            "PutManagedRuleSetVersions" => self.put_managed_rule_set_versions(&req),
193            "UpdateManagedRuleSetVersionExpiryDate" => {
194                self.update_managed_rule_set_version_expiry_date(&req)
195            }
196            "GenerateMobileSdkReleaseUrl" => self.generate_mobile_sdk_release_url(&req),
197            "GetMobileSdkRelease" => self.get_mobile_sdk_release(&req),
198            "ListMobileSdkReleases" => self.list_mobile_sdk_releases(&req),
199            "CheckCapacity" => self.check_capacity(&req),
200            "GetSampledRequests" => self.get_sampled_requests(&req),
201            "GetTopPathStatisticsByTraffic" => self.get_top_path_statistics_by_traffic(&req),
202            "GetRateBasedStatementManagedKeys" => self.get_rate_based_statement_managed_keys(&req),
203            "DeleteFirewallManagerRuleGroups" => self.delete_firewall_manager_rule_groups(&req),
204            other => Err(AwsServiceError::action_not_implemented("wafv2", other)),
205        }
206    }
207}
208
209// ─── WebACL ─────────────────────────────────────────────────────────
210
211impl Wafv2Service {}
212
213// ─── RuleGroup ─────────────────────────────────────────────────────
214
215impl Wafv2Service {}
216
217// ─── IPSet ─────────────────────────────────────────────────────────
218
219impl Wafv2Service {}
220
221// ─── RegexPatternSet ───────────────────────────────────────────────
222
223impl Wafv2Service {}
224
225// ─── Associations ───────────────────────────────────────────────────
226
227impl Wafv2Service {}
228
229// ─── Logging Config ────────────────────────────────────────────────
230
231impl Wafv2Service {}
232
233// ─── Permission Policy ─────────────────────────────────────────────
234
235impl Wafv2Service {}
236
237// ─── Tags ───────────────────────────────────────────────────────────
238
239impl Wafv2Service {}
240
241// ─── API Keys ───────────────────────────────────────────────────────
242
243impl Wafv2Service {}
244
245// ─── Managed rule sets / products ──────────────────────────────────
246
247impl Wafv2Service {}
248
249// ─── Mobile SDK ─────────────────────────────────────────────────────
250
251impl Wafv2Service {}
252
253// ─── Misc query / capacity ─────────────────────────────────────────
254
255impl Wafv2Service {
256    fn get_top_path_statistics_by_traffic(
257        &self,
258        req: &AwsRequest,
259    ) -> Result<AwsResponse, AwsServiceError> {
260        let body = req.json_body();
261        // Smithy member is `WebAclArn` (lowercase l), unlike most other ops
262        // which use `WebACLArn`. Match the model exactly.
263        let _web_acl_arn = require_str_len(&body, "WebAclArn", 20, 2048)?;
264        let _scope = require_scope(&body)?;
265        body.get("TimeWindow")
266            .ok_or_else(|| invalid_param("TimeWindow is required"))?;
267        let _limit = require_int_range(&body, "Limit", 1, 100)?;
268        let _bots_per_path = require_int_range(&body, "NumberOfTopTrafficBotsPerPath", 1, 10)?;
269        opt_str_len(&body, "UriPathPrefix", 1, 512)?;
270        opt_str_len(&body, "BotCategory", 1, 256)?;
271        opt_str_len(&body, "BotName", 1, 256)?;
272        opt_str_len(&body, "BotOrganization", 1, 256)?;
273        validate_opt_next_marker(&body)?;
274        Ok(AwsResponse::ok_json(json!({
275            "PathStatistics": [],
276            "TopCategories": [],
277            "TotalRequestCount": 0_u64,
278        })))
279    }
280}
281
282// ─── Helpers ────────────────────────────────────────────────────────
283
284fn account_mut<'a>(state: &'a mut Wafv2Accounts, account_id: &str) -> &'a mut AccountState {
285    state.accounts.entry(account_id.to_string()).or_default()
286}
287
288fn require_str(body: &Value, field: &str) -> Result<String, AwsServiceError> {
289    body.get(field)
290        .and_then(Value::as_str)
291        .map(str::to_owned)
292        .ok_or_else(|| invalid_param(format!("{field} is required")))
293}
294
295fn require_scope(body: &Value) -> Result<String, AwsServiceError> {
296    let scope = require_str(body, "Scope")?;
297    validate_enum(&scope, &["REGIONAL", "CLOUDFRONT"], "Scope")?;
298    Ok(scope)
299}
300
301/// Validate a string member against Smithy `@length(min, max)` constraints.
302fn validate_str_len(
303    value: &str,
304    min: usize,
305    max: usize,
306    field: &str,
307) -> Result<(), AwsServiceError> {
308    if value.len() < min || value.len() > max {
309        return Err(invalid_param(format!(
310            "{field} must be between {min} and {max} characters"
311        )));
312    }
313    Ok(())
314}
315
316/// Validate an integer member against Smithy `@range(min, max)` constraints.
317fn validate_int_range(value: i64, min: i64, max: i64, field: &str) -> Result<(), AwsServiceError> {
318    if value < min || value > max {
319        return Err(invalid_param(format!(
320            "{field} must be between {min} and {max}"
321        )));
322    }
323    Ok(())
324}
325
326/// Validate that a value is one of the allowed enum values.
327fn validate_enum(value: &str, allowed: &[&str], field: &str) -> Result<(), AwsServiceError> {
328    if !allowed.contains(&value) {
329        return Err(invalid_param(format!("Invalid {field}: {value}")));
330    }
331    Ok(())
332}
333
334/// Optional string with length bounds. Returns None when absent. When present
335/// the value is validated against `[min, max]`.
336fn opt_str_len(
337    body: &Value,
338    field: &str,
339    min: usize,
340    max: usize,
341) -> Result<Option<String>, AwsServiceError> {
342    match body.get(field).and_then(Value::as_str) {
343        Some(s) => {
344            validate_str_len(s, min, max, field)?;
345            Ok(Some(s.to_owned()))
346        }
347        None => Ok(None),
348    }
349}
350
351/// Required string with length bounds.
352fn require_str_len(
353    body: &Value,
354    field: &str,
355    min: usize,
356    max: usize,
357) -> Result<String, AwsServiceError> {
358    let s = require_str(body, field)?;
359    validate_str_len(&s, min, max, field)?;
360    Ok(s)
361}
362
363/// Optional integer with range bounds.
364fn opt_int_range(
365    body: &Value,
366    field: &str,
367    min: i64,
368    max: i64,
369) -> Result<Option<i64>, AwsServiceError> {
370    match body.get(field) {
371        Some(v) => {
372            let n = v
373                .as_i64()
374                .ok_or_else(|| invalid_param(format!("{field} must be an integer")))?;
375            validate_int_range(n, min, max, field)?;
376            Ok(Some(n))
377        }
378        None => Ok(None),
379    }
380}
381
382/// Required integer with range bounds.
383fn require_int_range(
384    body: &Value,
385    field: &str,
386    min: i64,
387    max: i64,
388) -> Result<i64, AwsServiceError> {
389    let v = body
390        .get(field)
391        .ok_or_else(|| invalid_param(format!("{field} is required")))?;
392    let n = v
393        .as_i64()
394        .ok_or_else(|| invalid_param(format!("{field} must be an integer")))?;
395    validate_int_range(n, min, max, field)?;
396    Ok(n)
397}
398
399/// Validate the standard `Limit` paging parameter when present. AWS WAFv2
400/// uses `@range(min=1, max=100)` on every list operation.
401fn validate_opt_limit(body: &Value) -> Result<(), AwsServiceError> {
402    opt_int_range(body, "Limit", 1, 100)?;
403    Ok(())
404}
405
406/// Validate the standard `NextMarker` paging token when present.
407/// AWS WAFv2 uses `@length(min=1, max=256)`.
408fn validate_opt_next_marker(body: &Value) -> Result<(), AwsServiceError> {
409    opt_str_len(body, "NextMarker", 1, 256)?;
410    Ok(())
411}
412
413fn invalid_param(msg: impl Into<String>) -> AwsServiceError {
414    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "WAFInvalidParameterException", msg)
415}
416
417/// Normalize an ELBv2-style `ResourceArn` to the canonical
418/// load-balancer ARN. Real AWS associates web ACLs with the load
419/// balancer (not the listener), but callers regularly pass through
420/// listener ARNs - either by accident or because they only have
421/// the listener handy in their CloudFormation template. Trim the
422/// listener suffix so the persisted association matches the lb
423/// arn the data plane looks up.
424///
425/// Non-ELBv2 ARNs (API Gateway, AppSync, Cognito, Verified Access)
426/// are returned unchanged.
427fn normalize_resource_arn(arn: &str) -> String {
428    // Listener ARN:
429    //   arn:aws:elasticloadbalancing:<region>:<acct>:listener/<type>/<name>/<lb-suffix>/<listener-suffix>
430    // LoadBalancer ARN:
431    //   arn:aws:elasticloadbalancing:<region>:<acct>:loadbalancer/<type>/<name>/<lb-suffix>
432    if let Some(rest) = arn.strip_prefix("arn:aws:elasticloadbalancing:") {
433        if let Some((before, after)) = rest.split_once(":listener/") {
434            // Listener path has 4 segments (<type>/<name>/<lb-suffix>/<listener-suffix>);
435            // drop the trailing listener suffix to recover the lb ARN.
436            let mut parts = after.splitn(4, '/');
437            let ty = parts.next();
438            let name = parts.next();
439            let lb_suffix = parts.next();
440            if let (Some(ty), Some(name), Some(lb_suffix)) = (ty, name, lb_suffix) {
441                return format!(
442                    "arn:aws:elasticloadbalancing:{before}:loadbalancer/{ty}/{name}/{lb_suffix}"
443                );
444            }
445        }
446    }
447    arn.to_string()
448}
449
450fn not_found(resource: &str) -> AwsServiceError {
451    AwsServiceError::aws_error(
452        StatusCode::BAD_REQUEST,
453        "WAFNonexistentItemException",
454        format!("{resource} not found"),
455    )
456}
457
458fn already_exists(msg: &str) -> AwsServiceError {
459    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "WAFDuplicateItemException", msg)
460}
461
462fn stale_lock_token() -> AwsServiceError {
463    AwsServiceError::aws_error(
464        StatusCode::BAD_REQUEST,
465        "WAFOptimisticLockException",
466        "LockToken does not match the current value; refresh and retry",
467    )
468}
469
470fn synth_uuid() -> String {
471    Uuid::new_v4().to_string()
472}
473
474fn synth_arn(
475    account_id: &str,
476    region: &str,
477    scope: &str,
478    kind: &str,
479    name: &str,
480    id: &str,
481) -> String {
482    let region = if region.is_empty() {
483        "us-east-1"
484    } else {
485        region
486    };
487    // Real AWS WAF v2 CLOUDFRONT-scope ARNs always use `us-east-1` as the
488    // region segment plus a `global/...` resource path. REGIONAL ARNs use
489    // the caller's region with the region as the resource-path prefix.
490    let (region_in_arn, scope_seg) = if scope == "CLOUDFRONT" {
491        ("us-east-1", "global")
492    } else {
493        (region, region)
494    };
495    Arn::new(
496        "wafv2",
497        region_in_arn,
498        account_id,
499        &format!("{scope_seg}/{kind}/{name}/{id}"),
500    )
501    .to_string()
502}
503
504fn parse_string_list(value: Option<&Value>) -> Vec<String> {
505    value
506        .and_then(Value::as_array)
507        .map(|v| {
508            v.iter()
509                .filter_map(|s| s.as_str().map(str::to_owned))
510                .collect()
511        })
512        .unwrap_or_default()
513}
514
515fn parse_tags(value: Option<&Value>) -> Result<BTreeMap<String, String>, AwsServiceError> {
516    let mut out = BTreeMap::new();
517    let Some(arr) = value.and_then(Value::as_array) else {
518        return Ok(out);
519    };
520    for tag in arr {
521        let key = tag
522            .get("Key")
523            .and_then(Value::as_str)
524            .ok_or_else(|| invalid_param("Tag.Key is required"))?
525            .to_string();
526        let value = tag
527            .get("Value")
528            .and_then(Value::as_str)
529            .unwrap_or_default()
530            .to_string();
531        out.insert(key, value);
532    }
533    Ok(out)
534}
535
536fn parse_custom_response_bodies(value: Option<&Value>) -> BTreeMap<String, Value> {
537    value
538        .and_then(Value::as_object)
539        .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
540        .unwrap_or_default()
541}
542
543fn resource_exists(account: &AccountState, arn: &str) -> bool {
544    account.web_acls.values().any(|w| w.arn == arn)
545        || account.rule_groups.values().any(|r| r.arn == arn)
546        || account.ip_sets.values().any(|s| s.arn == arn)
547        || account.regex_pattern_sets.values().any(|s| s.arn == arn)
548}
549
550/// WCU cost is 1 per leaf statement in real WAF. fakecloud uses the
551/// recursive count of statement leaves as a stand-in — close enough for
552/// CheckCapacity round-tripping and for the WAFLimitsExceeded path.
553fn compute_capacity(rules: &[Value]) -> i64 {
554    rules
555        .iter()
556        .map(|r| r.get("Statement").map(count_statement_leaves).unwrap_or(1) as i64)
557        .sum()
558}
559
560fn count_statement_leaves(stmt: &Value) -> u32 {
561    let Some(obj) = stmt.as_object() else {
562        return 1;
563    };
564    let mut total = 0u32;
565    for (k, v) in obj {
566        match k.as_str() {
567            "AndStatement" | "OrStatement" => {
568                if let Some(arr) = v.get("Statements").and_then(Value::as_array) {
569                    for s in arr {
570                        total += count_statement_leaves(s);
571                    }
572                }
573            }
574            "NotStatement" => {
575                if let Some(s) = v.get("Statement") {
576                    total += count_statement_leaves(s);
577                }
578            }
579            _ => {
580                total += 1;
581            }
582        }
583    }
584    total.max(1)
585}
586
587fn managed_products() -> Vec<Value> {
588    vec![
589        json!({
590            "VendorName": "AWS",
591            "ManagedRuleSetName": "AWSManagedRulesCommonRuleSet",
592            "ProductId": "prod-aws-common",
593            "ProductLink": "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html",
594            "ProductTitle": "Core rule set",
595            "ProductDescription": "OWASP Top 10 baseline rules",
596            "SnsTopicArn": "arn:aws:sns:us-east-1::aws-managed-common-notifications",
597            "IsVersioningSupported": true,
598            "IsAdvancedManagedRuleSet": false,
599        }),
600        json!({
601            "VendorName": "AWS",
602            "ManagedRuleSetName": "AWSManagedRulesSQLiRuleSet",
603            "ProductId": "prod-aws-sqli",
604            "ProductLink": "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html",
605            "ProductTitle": "SQL injection rule set",
606            "ProductDescription": "Rules that block SQL injection patterns",
607            "SnsTopicArn": "arn:aws:sns:us-east-1::aws-managed-sqli-notifications",
608            "IsVersioningSupported": true,
609            "IsAdvancedManagedRuleSet": false,
610        }),
611    ]
612}
613
614fn managed_rule_summaries(_vendor: &str, _name: &str) -> Vec<Value> {
615    vec![json!({
616        "Name": "RuleA",
617        "Action": {"Block": {}},
618    })]
619}
620
621// ─── JSON shaping ──────────────────────────────────────────────────
622
623fn web_acl_summary_json(
624    id: &str,
625    name: &str,
626    arn: &str,
627    description: Option<&str>,
628    lock_token: &str,
629) -> Value {
630    let mut obj = json!({
631        "Id": id,
632        "Name": name,
633        "ARN": arn,
634        "LockToken": lock_token,
635    });
636    if let Some(d) = description {
637        obj.as_object_mut()
638            .unwrap()
639            .insert("Description".to_string(), Value::String(d.to_string()));
640    }
641    obj
642}
643
644fn web_acl_detail_json(acl: &WebAcl) -> Value {
645    let mut obj = json!({
646        "Id": acl.id,
647        "Name": acl.name,
648        "ARN": acl.arn,
649        "DefaultAction": acl.default_action,
650        "Rules": acl.rules,
651        "VisibilityConfig": acl.visibility_config,
652        "Capacity": acl.capacity,
653        "ManagedByFirewallManager": acl.managed_by_firewall_manager,
654        "RetrofittedByFirewallManager": acl.retrofitted_by_firewall_manager,
655        "LabelNamespace": acl.label_namespace,
656        "TokenDomains": acl.token_domains,
657        "PreProcessFirewallManagerRuleGroups": acl.pre_process_firewall_manager_rule_groups,
658        "PostProcessFirewallManagerRuleGroups": acl.post_process_firewall_manager_rule_groups,
659    });
660    if let Some(d) = &acl.description {
661        obj.as_object_mut()
662            .unwrap()
663            .insert("Description".to_string(), Value::String(d.clone()));
664    }
665    if !acl.custom_response_bodies.is_empty() {
666        obj.as_object_mut().unwrap().insert(
667            "CustomResponseBodies".to_string(),
668            json!(acl.custom_response_bodies),
669        );
670    }
671    if let Some(c) = &acl.captcha_config {
672        obj.as_object_mut()
673            .unwrap()
674            .insert("CaptchaConfig".to_string(), c.clone());
675    }
676    if let Some(c) = &acl.challenge_config {
677        obj.as_object_mut()
678            .unwrap()
679            .insert("ChallengeConfig".to_string(), c.clone());
680    }
681    if let Some(c) = &acl.association_config {
682        obj.as_object_mut()
683            .unwrap()
684            .insert("AssociationConfig".to_string(), c.clone());
685    }
686    if let Some(c) = &acl.data_protection_config {
687        obj.as_object_mut()
688            .unwrap()
689            .insert("DataProtectionConfig".to_string(), c.clone());
690    }
691    if let Some(c) = &acl.on_source_d_do_s_protection_config {
692        obj.as_object_mut()
693            .unwrap()
694            .insert("OnSourceDDoSProtectionConfig".to_string(), c.clone());
695    }
696    if let Some(c) = &acl.application_config {
697        obj.as_object_mut()
698            .unwrap()
699            .insert("ApplicationConfig".to_string(), c.clone());
700    }
701    obj
702}
703
704fn rule_group_summary_json(
705    id: &str,
706    name: &str,
707    arn: &str,
708    description: Option<&str>,
709    lock_token: &str,
710) -> Value {
711    let mut obj = json!({
712        "Id": id,
713        "Name": name,
714        "ARN": arn,
715        "LockToken": lock_token,
716    });
717    if let Some(d) = description {
718        obj.as_object_mut()
719            .unwrap()
720            .insert("Description".to_string(), Value::String(d.to_string()));
721    }
722    obj
723}
724
725fn rule_group_detail_json(rg: &RuleGroup) -> Value {
726    let mut obj = json!({
727        "Id": rg.id,
728        "Name": rg.name,
729        "ARN": rg.arn,
730        "Capacity": rg.capacity,
731        "Rules": rg.rules,
732        "VisibilityConfig": rg.visibility_config,
733        "LabelNamespace": rg.label_namespace,
734        "AvailableLabels": rg.available_labels,
735        "ConsumedLabels": rg.consumed_labels,
736    });
737    if let Some(d) = &rg.description {
738        obj.as_object_mut()
739            .unwrap()
740            .insert("Description".to_string(), Value::String(d.clone()));
741    }
742    if !rg.custom_response_bodies.is_empty() {
743        obj.as_object_mut().unwrap().insert(
744            "CustomResponseBodies".to_string(),
745            json!(rg.custom_response_bodies),
746        );
747    }
748    obj
749}
750
751fn ip_set_summary_json(
752    id: &str,
753    name: &str,
754    arn: &str,
755    description: Option<&str>,
756    lock_token: &str,
757) -> Value {
758    let mut obj = json!({
759        "Id": id,
760        "Name": name,
761        "ARN": arn,
762        "LockToken": lock_token,
763    });
764    if let Some(d) = description {
765        obj.as_object_mut()
766            .unwrap()
767            .insert("Description".to_string(), Value::String(d.to_string()));
768    }
769    obj
770}
771
772fn ip_set_detail_json(set: &IpSet) -> Value {
773    let mut obj = json!({
774        "Id": set.id,
775        "Name": set.name,
776        "ARN": set.arn,
777        "IPAddressVersion": set.ip_address_version,
778        "Addresses": set.addresses,
779    });
780    if let Some(d) = &set.description {
781        obj.as_object_mut()
782            .unwrap()
783            .insert("Description".to_string(), Value::String(d.clone()));
784    }
785    obj
786}
787
788fn regex_set_summary_json(
789    id: &str,
790    name: &str,
791    arn: &str,
792    description: Option<&str>,
793    lock_token: &str,
794) -> Value {
795    let mut obj = json!({
796        "Id": id,
797        "Name": name,
798        "ARN": arn,
799        "LockToken": lock_token,
800    });
801    if let Some(d) = description {
802        obj.as_object_mut()
803            .unwrap()
804            .insert("Description".to_string(), Value::String(d.to_string()));
805    }
806    obj
807}
808
809fn regex_set_detail_json(set: &RegexPatternSet) -> Value {
810    let mut obj = json!({
811        "Id": set.id,
812        "Name": set.name,
813        "ARN": set.arn,
814        "RegularExpressionList": set.regular_expressions,
815    });
816    if let Some(d) = &set.description {
817        obj.as_object_mut()
818            .unwrap()
819            .insert("Description".to_string(), Value::String(d.clone()));
820    }
821    obj
822}
823
824#[cfg(test)]
825mod arn_norm_tests {
826    use super::normalize_resource_arn;
827
828    #[test]
829    fn elb_listener_arn_collapses_to_load_balancer_arn() {
830        let listener =
831            "arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/app/web/abc/xyz";
832        assert_eq!(
833            normalize_resource_arn(listener),
834            "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/web/abc"
835        );
836    }
837
838    #[test]
839    fn elb_load_balancer_arn_passes_through() {
840        let lb = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/web/abc";
841        assert_eq!(normalize_resource_arn(lb), lb);
842    }
843
844    #[test]
845    fn nlb_listener_arn_collapses_to_network_load_balancer_arn() {
846        let listener =
847            "arn:aws:elasticloadbalancing:eu-west-1:123456789012:listener/net/wire/abc/xyz";
848        assert_eq!(
849            normalize_resource_arn(listener),
850            "arn:aws:elasticloadbalancing:eu-west-1:123456789012:loadbalancer/net/wire/abc"
851        );
852    }
853
854    #[test]
855    fn non_elbv2_arn_passes_through() {
856        let apigw = "arn:aws:apigateway:us-east-1::/restapis/abc/stages/prod";
857        assert_eq!(normalize_resource_arn(apigw), apigw);
858        let cog = "arn:aws:cognito-idp:us-east-1:123456789012:userpool/us-east-1_xxx";
859        assert_eq!(normalize_resource_arn(cog), cog);
860    }
861}
862
863#[cfg(test)]
864mod managed_rule_set_validation_tests {
865    use super::*;
866
867    fn make_body(fields: &[(&str, &str)]) -> Value {
868        let mut m = serde_json::Map::new();
869        for (k, v) in fields {
870            m.insert(k.to_string(), Value::String(v.to_string()));
871        }
872        Value::Object(m)
873    }
874
875    #[test]
876    fn put_managed_rule_set_versions_rejects_empty_name() {
877        let body = make_body(&[
878            ("Name", ""),
879            ("Id", "id"),
880            ("LockToken", "tok"),
881            ("Scope", "REGIONAL"),
882            ("RecommendedVersion", "1.0"),
883        ]);
884        let svc = Wafv2Service::default();
885        let req = AwsRequest {
886            service: "wafv2".into(),
887            action: "PutManagedRuleSetVersions".into(),
888            method: http::Method::POST,
889            raw_path: "/".into(),
890            raw_query: String::new(),
891            path_segments: Vec::new(),
892            query_params: std::collections::HashMap::new(),
893            headers: http::HeaderMap::new(),
894            body: serde_json::to_vec(&body).unwrap().into(),
895            body_stream: parking_lot::Mutex::new(None),
896            account_id: "123456789012".into(),
897            region: "us-east-1".into(),
898            request_id: "r".into(),
899            is_query_protocol: false,
900            access_key_id: None,
901            principal: None,
902        };
903        let res = svc.put_managed_rule_set_versions(&req);
904        assert!(res.is_err());
905        assert_eq!(res.err().unwrap().code(), "ValidationException");
906    }
907
908    #[test]
909    fn put_managed_rule_set_versions_rejects_long_name() {
910        let body = make_body(&[
911            ("Name", &"x".repeat(129)),
912            ("Id", "id"),
913            ("LockToken", "tok"),
914            ("Scope", "REGIONAL"),
915            ("RecommendedVersion", "1.0"),
916        ]);
917        let svc = Wafv2Service::default();
918        let req = AwsRequest {
919            service: "wafv2".into(),
920            action: "PutManagedRuleSetVersions".into(),
921            method: http::Method::POST,
922            raw_path: "/".into(),
923            raw_query: String::new(),
924            path_segments: Vec::new(),
925            query_params: std::collections::HashMap::new(),
926            headers: http::HeaderMap::new(),
927            body: serde_json::to_vec(&body).unwrap().into(),
928            body_stream: parking_lot::Mutex::new(None),
929            account_id: "123456789012".into(),
930            region: "us-east-1".into(),
931            request_id: "r".into(),
932            is_query_protocol: false,
933            access_key_id: None,
934            principal: None,
935        };
936        let res = svc.put_managed_rule_set_versions(&req);
937        assert!(res.is_err());
938        assert_eq!(res.err().unwrap().code(), "ValidationException");
939    }
940
941    #[test]
942    fn update_managed_rule_set_version_expiry_date_rejects_missing_timestamp() {
943        let body = make_body(&[
944            ("Name", "name"),
945            ("Id", "id"),
946            ("LockToken", "tok"),
947            ("Scope", "REGIONAL"),
948            ("VersionToExpire", "1.0"),
949        ]);
950        let svc = Wafv2Service::default();
951        let req = AwsRequest {
952            service: "wafv2".into(),
953            action: "UpdateManagedRuleSetVersionExpiryDate".into(),
954            method: http::Method::POST,
955            raw_path: "/".into(),
956            raw_query: String::new(),
957            path_segments: Vec::new(),
958            query_params: std::collections::HashMap::new(),
959            headers: http::HeaderMap::new(),
960            body: serde_json::to_vec(&body).unwrap().into(),
961            body_stream: parking_lot::Mutex::new(None),
962            account_id: "123456789012".into(),
963            region: "us-east-1".into(),
964            request_id: "r".into(),
965            is_query_protocol: false,
966            access_key_id: None,
967            principal: None,
968        };
969        let res = svc.update_managed_rule_set_version_expiry_date(&req);
970        assert!(res.is_err());
971        assert_eq!(res.err().unwrap().code(), "WAFInvalidParameterException");
972    }
973
974    // bug-audit 2026-05-28, 1.7: List* operations reject a malformed NextMarker
975    // (paginate_checked -> WAFInvalidParameterException) instead of silently
976    // restarting at page 0.
977    #[test]
978    fn paginate_checked_rejects_invalid_token() {
979        use fakecloud_core::pagination::paginate_checked;
980        let items: Vec<i32> = (0..5).collect();
981        assert!(paginate_checked(&items, Some("not-a-valid-token"), 3).is_err());
982        assert!(paginate_checked(&items, Some("2"), 3).is_ok());
983        assert!(paginate_checked(&items, None, 3).is_ok());
984    }
985}