Skip to main content

fakecloud_core/
protocol.rs

1use bytes::Bytes;
2use http::HeaderMap;
3use std::collections::HashMap;
4
5/// The wire protocol used by an AWS service.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum AwsProtocol {
8    /// Query protocol: form-encoded body, Action param, XML response.
9    /// Used by: SQS, SNS, IAM, STS.
10    Query,
11    /// JSON protocol: JSON body, X-Amz-Target header, JSON response.
12    /// Used by: SSM, EventBridge, DynamoDB, SecretsManager, KMS, CloudWatch Logs.
13    Json,
14    /// REST protocol: HTTP method + path-based routing, XML responses.
15    /// Used by: S3, API Gateway, Route53.
16    Rest,
17    /// REST-JSON protocol: HTTP method + path-based routing, JSON responses.
18    /// Used by: Lambda, SES v2.
19    RestJson,
20}
21
22/// Services that use REST protocol with XML responses (detected from SigV4 credential scope).
23const REST_XML_SERVICES: &[&str] = &["s3", "cloudfront", "route53"];
24
25/// Services that use REST protocol with JSON responses (detected from SigV4 credential scope).
26const REST_JSON_SERVICES: &[&str] = &[
27    "lambda",
28    "ses",
29    "apigateway",
30    "bedrock",
31    "bedrock-agent",
32    "bedrock-agent-runtime",
33    "scheduler",
34    "batch",
35    "pipes",
36    "rds-data",
37    "dsql",
38    "resource-groups",
39    "eks",
40    "backup",
41    // Amazon OpenSearch Service + Amazon Elasticsearch Service both sign as
42    // `es` and speak restJson1; one service handles both, splitting on the
43    // URL path version prefix.
44    "es",
45    "account",
46];
47
48/// Detected service name and action from an incoming HTTP request.
49#[derive(Debug, Clone)]
50pub struct DetectedRequest {
51    pub service: String,
52    pub action: String,
53    pub protocol: AwsProtocol,
54}
55
56/// Header-only service detection. Skips the form-encoded body sniff so
57/// the dispatch path can decide whether to stream or buffer the body
58/// without first reading it. Returns `None` when only a body sniff
59/// would succeed; the caller must then fall back to [`detect_service`]
60/// after buffering. Used to opt streaming routes (S3 PutObject /
61/// UploadPart, ECR OCI v2 blob upload) out of the global body cap.
62pub fn detect_service_headers_only(
63    headers: &HeaderMap,
64    query_params: &HashMap<String, String>,
65) -> Option<DetectedRequest> {
66    // Mirrors `detect_service` minus step 3 (form-body sniff).
67    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
68        return parse_amz_target(target);
69    }
70    if let Some(action) = query_params.get("Action") {
71        let service = extract_service_from_auth(headers)
72            .or_else(|| infer_service_from_action(action))
73            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
74        if let Some(service) = service {
75            return Some(DetectedRequest {
76                service,
77                action: action.clone(),
78                protocol: AwsProtocol::Query,
79            });
80        }
81    }
82    if let Some(service) = extract_service_from_auth(headers) {
83        if let Some(protocol) = rest_protocol_for(&service) {
84            return Some(DetectedRequest {
85                service,
86                action: String::new(),
87                protocol,
88            });
89        }
90    }
91    if let Some(credential) = query_params.get("X-Amz-Credential") {
92        let parts: Vec<&str> = credential.split('/').collect();
93        if parts.len() >= 4 {
94            let service = normalize_service_name(parts[3]).to_string();
95            if let Some(protocol) = rest_protocol_for(&service) {
96                return Some(DetectedRequest {
97                    service,
98                    action: String::new(),
99                    protocol,
100                });
101            }
102        }
103    }
104    if query_params.contains_key("AWSAccessKeyId")
105        && query_params.contains_key("Signature")
106        && query_params.contains_key("Expires")
107    {
108        return Some(DetectedRequest {
109            service: "s3".to_string(),
110            action: String::new(),
111            protocol: AwsProtocol::Rest,
112        });
113    }
114    if let Some(host_info) = parse_routing_host_from_headers(headers) {
115        if let Some(protocol) = rest_protocol_for(&host_info.service) {
116            return Some(DetectedRequest {
117                service: host_info.service,
118                action: String::new(),
119                protocol,
120            });
121        }
122    }
123    None
124}
125
126/// Detect the target service and action from HTTP request components.
127pub fn detect_service(
128    headers: &HeaderMap,
129    query_params: &HashMap<String, String>,
130    body: &Bytes,
131) -> Option<DetectedRequest> {
132    // 1. Check X-Amz-Target header (JSON protocol)
133    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
134        return parse_amz_target(target);
135    }
136
137    // 2. Check for Query protocol (Action parameter in query string or form body)
138    if let Some(action) = query_params.get("Action") {
139        let service = extract_service_from_auth(headers)
140            .or_else(|| infer_service_from_action(action))
141            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
142        if let Some(service) = service {
143            return Some(DetectedRequest {
144                service,
145                action: action.clone(),
146                protocol: AwsProtocol::Query,
147            });
148        }
149    }
150
151    // 3. Try form-encoded body
152    {
153        let form_params = decode_form_urlencoded(body);
154
155        if let Some(action) = form_params.get("Action") {
156            let service = extract_service_from_auth(headers)
157                .or_else(|| infer_service_from_action(action))
158                .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
159            if let Some(service) = service {
160                return Some(DetectedRequest {
161                    service,
162                    action: action.clone(),
163                    protocol: AwsProtocol::Query,
164                });
165            }
166        }
167    }
168
169    // 4. Fallback: check auth header for REST-style services (S3, Lambda, SES, etc.)
170    if let Some(service) = extract_service_from_auth(headers) {
171        if let Some(protocol) = rest_protocol_for(&service) {
172            return Some(DetectedRequest {
173                service,
174                action: String::new(), // REST services determine action from method+path
175                protocol,
176            });
177        }
178    }
179
180    // 5. Check query params for presigned URL auth (X-Amz-Credential for SigV4)
181    if let Some(credential) = query_params.get("X-Amz-Credential") {
182        // Format: AKID/date/region/service/aws4_request
183        let parts: Vec<&str> = credential.split('/').collect();
184        if parts.len() >= 4 {
185            let service = normalize_service_name(parts[3]).to_string();
186            if let Some(protocol) = rest_protocol_for(&service) {
187                return Some(DetectedRequest {
188                    service,
189                    action: String::new(),
190                    protocol,
191                });
192            }
193        }
194    }
195
196    // 6. Check for SigV2-style presigned URL (AWSAccessKeyId + Signature + Expires)
197    //    Only match when all three SigV2 presigned-URL parameters are present so
198    //    we don't accidentally claim non-S3 requests.
199    if query_params.contains_key("AWSAccessKeyId")
200        && query_params.contains_key("Signature")
201        && query_params.contains_key("Expires")
202    {
203        return Some(DetectedRequest {
204            service: "s3".to_string(),
205            action: String::new(),
206            protocol: AwsProtocol::Rest,
207        });
208    }
209
210    // 7. Fallback: unsigned REST-style request carrying a LocalStack-shaped
211    //    Host header. Lets fixtures and curl-style probes reach the right
212    //    service without SigV4; signed requests were already handled in step 4.
213    if let Some(host_info) = parse_routing_host_from_headers(headers) {
214        if let Some(protocol) = rest_protocol_for(&host_info.service) {
215            return Some(DetectedRequest {
216                service: host_info.service,
217                action: String::new(),
218                protocol,
219            });
220        }
221    }
222
223    None
224}
225
226/// Service + region (and optional bucket) decoded from a `Host` header.
227/// Covers both the LocalStack hostname convention
228/// (`<service>.<region>.localhost.localstack.cloud[:port]`,
229/// `<bucket>.s3.<region>.localhost.localstack.cloud[:port]`) and real AWS
230/// service hostnames (`<service>.<region>.amazonaws.com`, S3 path-style
231/// and virtual-hosted-style including the legacy no-region
232/// `s3.amazonaws.com` / `<bucket>.s3.amazonaws.com` forms and the older
233/// dash-separated `s3-<region>.amazonaws.com` form).
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct RoutingHost {
236    pub service: String,
237    pub region: String,
238    /// Set only for virtual-hosted-style S3 hostnames.
239    pub bucket: Option<String>,
240}
241
242const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
243const AWS_SUFFIX: &str = ".amazonaws.com";
244
245/// Parse a `Host` header value for a LocalStack- or AWS-shaped hostname.
246/// Returns `None` for anything that doesn't match — callers fall through
247/// to their existing detection path.
248pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
249    let hostname = host.split(':').next()?;
250    if hostname.is_empty() {
251        return None;
252    }
253    let hostname = hostname.to_ascii_lowercase();
254    if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
255        return parse_localstack_prefix(prefix);
256    }
257    if hostname == "amazonaws.com" {
258        return None;
259    }
260    if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
261        return parse_aws_prefix(prefix);
262    }
263    None
264}
265
266/// Pull the `Host` header and parse it with [`parse_routing_host`].
267pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
268    let host = headers.get("host")?.to_str().ok()?;
269    parse_routing_host(host)
270}
271
272fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
273    if prefix.is_empty() {
274        return None;
275    }
276    let labels: Vec<&str> = prefix.split('.').collect();
277    if labels.iter().any(|l| l.is_empty()) {
278        return None;
279    }
280    match labels.len() {
281        2 => Some(RoutingHost {
282            service: labels[0].to_string(),
283            region: labels[1].to_string(),
284            bucket: None,
285        }),
286        n if n >= 3 && labels[n - 2] == "s3" => {
287            let bucket = labels[..n - 2].join(".");
288            Some(RoutingHost {
289                service: "s3".to_string(),
290                region: labels[n - 1].to_string(),
291                bucket: Some(bucket),
292            })
293        }
294        n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
295            let bucket = labels[..n - 2].join(".");
296            Some(RoutingHost {
297                service: "s3".to_string(),
298                region: labels[n - 1].to_string(),
299                bucket: Some(bucket),
300            })
301        }
302        n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
303            service: "s3".to_string(),
304            region: labels[n - 1].to_string(),
305            bucket: None,
306        }),
307        _ => None,
308    }
309}
310
311/// Parse the prefix before `.amazonaws.com`.
312///
313/// Handles every variant AWS has shipped for the common REST/Query services:
314///
315/// - `<service>.<region>` — modern regional endpoint (most services).
316/// - `s3.<region>` — modern path-style S3.
317/// - `<bucket>.s3.<region>` — modern virtual-hosted S3 (bucket may contain dots).
318/// - `s3` — legacy S3 global endpoint (implicitly `us-east-1`).
319/// - `<bucket>.s3` — legacy virtual-hosted S3 (implicitly `us-east-1`).
320/// - `s3-<region>` — older dash-separated path-style S3.
321/// - `<bucket>.s3-<region>` — older dash-separated virtual-hosted S3.
322fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
323    if prefix.is_empty() {
324        return None;
325    }
326    let labels: Vec<&str> = prefix.split('.').collect();
327    if labels.iter().any(|l| l.is_empty()) {
328        return None;
329    }
330    let last = *labels.last()?;
331
332    // `s3-<region>` as the last label: dash-separated S3. Bucket, if any,
333    // is whatever precedes it.
334    if let Some(region) = last.strip_prefix("s3-") {
335        if !region.is_empty() {
336            let bucket = if labels.len() >= 2 {
337                Some(labels[..labels.len() - 1].join("."))
338            } else {
339                None
340            };
341            return Some(RoutingHost {
342                service: "s3".to_string(),
343                region: region.to_string(),
344                bucket,
345            });
346        }
347    }
348
349    // Legacy global S3: last label is `s3`, no region present. `s3` on its
350    // own is the path-style global endpoint; anything preceding it is the
351    // bucket (including dotted names like `a.b.s3.amazonaws.com`).
352    if last == "s3" {
353        if labels.len() == 1 {
354            return Some(RoutingHost {
355                service: "s3".to_string(),
356                region: "us-east-1".to_string(),
357                bucket: None,
358            });
359        }
360        return Some(RoutingHost {
361            service: "s3".to_string(),
362            region: "us-east-1".to_string(),
363            bucket: Some(labels[..labels.len() - 1].join(".")),
364        });
365    }
366
367    // `s3-accesspoint.<region>` — path-style access point endpoint.
368    // `{alias}-{account-id}.s3-accesspoint.<region>` — virtual-hosted access point.
369    if last == "s3-accesspoint" {
370        if labels.len() == 2 {
371            return Some(RoutingHost {
372                service: "s3".to_string(),
373                region: labels[0].to_string(),
374                bucket: None,
375            });
376        }
377        // Virtual-hosted form needs at least {alias}.{region}.s3-accesspoint, i.e.
378        // 3+ labels. A bare "s3-accesspoint" host (1 label) must not reach the
379        // `len() - 2` slice, which would underflow and panic.
380        if labels.len() >= 3 {
381            let bucket = labels[..labels.len() - 2].join(".");
382            return Some(RoutingHost {
383                service: "s3".to_string(),
384                region: labels[labels.len() - 1].to_string(),
385                bucket: Some(bucket),
386            });
387        }
388    }
389
390    // `s3-control.<region>` or `{account-id}.s3-control.<region>` — S3
391    // Control endpoint (access point management).
392    if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
393        return Some(RoutingHost {
394            service: "s3".to_string(),
395            region: last.to_string(),
396            bucket: None,
397        });
398    }
399
400    match labels.len() {
401        // `<service>.<region>` — the common case. Covers `s3.<region>`
402        // path-style S3 too, since the service label falls through here.
403        2 => Some(RoutingHost {
404            service: labels[0].to_string(),
405            region: labels[1].to_string(),
406            bucket: None,
407        }),
408        // `<bucket>.s3.<region>` — modern virtual-hosted S3.
409        n if n >= 3 && labels[n - 2] == "s3" => {
410            let bucket = labels[..n - 2].join(".");
411            Some(RoutingHost {
412                service: "s3".to_string(),
413                region: labels[n - 1].to_string(),
414                bucket: Some(bucket),
415            })
416        }
417        _ => None,
418    }
419}
420
421/// Parse `X-Amz-Target: AWSEvents.PutEvents` -> service=events, action=PutEvents
422/// Parse `X-Amz-Target: AmazonSSM.GetParameter` -> service=ssm, action=GetParameter
423fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
424    let (prefix, action) = target.rsplit_once('.')?;
425
426    let service = match prefix {
427        "AWSEvents" => "events",
428        "AmazonSSM" => "ssm",
429        "AmazonSQS" => "sqs",
430        "AmazonSNS" => "sns",
431        "DynamoDB_20120810" => "dynamodb",
432        "DynamoDBStreams_20120810" => "dynamodbstreams",
433        "Logs_20140328" => "logs",
434        s if s.starts_with("secretsmanager") => "secretsmanager",
435        s if s.starts_with("TrentService") => "kms",
436        s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
437        s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
438        s if s.starts_with("Kinesis_20131202") => "kinesis",
439        s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
440        s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
441        s if s.starts_with("AWSStepFunctions") => "states",
442        s if s.starts_with("AWSOrganizationsV") => "organizations",
443        "CertificateManager" => "acm",
444        "AnyScaleFrontendService" => "application-autoscaling",
445        // Match the WAFv2 target version exactly so legacy WAF Classic
446        // (`AWSWAF_*` without the `_20190729` suffix) doesn't get routed here.
447        "AWSWAF_20190729" => "wafv2",
448        "AmazonAthena" => "athena",
449        s if s.starts_with("Firehose_") => "firehose",
450        "AWSGlue" => "glue",
451        "CloudApiService" => "cloudcontrolapi",
452        "ResourceGroupsTaggingAPI_20170126" => "tagging",
453        "AmazonMemoryDB" => "memorydb",
454        // Cloud Map (servicediscovery): awsJson1.1, target prefix carries the
455        // dated Route53 Auto Naming service version.
456        "Route53AutoNaming_v20170314" => "servicediscovery",
457        // Database Migration Service: awsJson1.1.
458        "AmazonDMSv20160101" => "dms",
459        // IAM Identity Center Identity Store: awsJson1.1.
460        "AWSIdentityStore" => "identitystore",
461        // IAM Identity Center SSO Admin: awsJson1.1.
462        "SWBExternalService" => "sso",
463        // Verified Permissions: awsJson1.0.
464        "VerifiedPermissions" => "verifiedpermissions",
465        // CloudWatch advertises awsJson1_0 (target service shape
466        // `GraniteServiceVersion20100801`) alongside the legacy awsQuery
467        // protocol. Newer SDKs (aws-sdk-rust / js-v3 / go-v2) POST with
468        // `X-Amz-Target: GraniteServiceVersion20100801.<Operation>` and a JSON
469        // body. The service registry key is `monitoring`.
470        s if s.starts_with("GraniteServiceVersion") => "monitoring",
471        _ => return None,
472    };
473
474    Some(DetectedRequest {
475        service: service.to_string(),
476        action: action.to_string(),
477        protocol: AwsProtocol::Json,
478    })
479}
480
481/// Returns the REST protocol variant for a service, or None if not a REST service.
482fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
483    if REST_XML_SERVICES.contains(&service) {
484        Some(AwsProtocol::Rest)
485    } else if REST_JSON_SERVICES.contains(&service) {
486        Some(AwsProtocol::RestJson)
487    } else {
488        None
489    }
490}
491
492/// Infer service from the action name when no SigV4 auth is present.
493/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
494/// do not require authentication and won't have an Authorization header.
495fn infer_service_from_action(action: &str) -> Option<String> {
496    match action {
497        "AssumeRole"
498        | "AssumeRoleWithSAML"
499        | "AssumeRoleWithWebIdentity"
500        | "GetCallerIdentity"
501        | "GetSessionToken"
502        | "GetFederationToken"
503        | "GetAccessKeyInfo"
504        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
505        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
506        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
507        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
508        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
509        // SES v1 (Query protocol)
510        "VerifyEmailIdentity"
511        | "VerifyDomainIdentity"
512        | "VerifyDomainDkim"
513        | "ListIdentities"
514        | "GetIdentityVerificationAttributes"
515        | "GetIdentityDkimAttributes"
516        | "DeleteIdentity"
517        | "SetIdentityDkimEnabled"
518        | "SetIdentityNotificationTopic"
519        | "SetIdentityFeedbackForwardingEnabled"
520        | "GetIdentityNotificationAttributes"
521        | "GetIdentityMailFromDomainAttributes"
522        | "SetIdentityMailFromDomain"
523        | "SendEmail"
524        | "SendRawEmail"
525        | "SendTemplatedEmail"
526        | "SendBulkTemplatedEmail"
527        | "CreateTemplate"
528        | "GetTemplate"
529        | "ListTemplates"
530        | "DeleteTemplate"
531        | "UpdateTemplate"
532        | "CreateConfigurationSet"
533        | "DeleteConfigurationSet"
534        | "DescribeConfigurationSet"
535        | "ListConfigurationSets"
536        | "CreateConfigurationSetEventDestination"
537        | "UpdateConfigurationSetEventDestination"
538        | "DeleteConfigurationSetEventDestination"
539        | "GetSendQuota"
540        | "GetSendStatistics"
541        | "GetAccountSendingEnabled"
542        | "CreateReceiptRuleSet"
543        | "DeleteReceiptRuleSet"
544        | "DescribeReceiptRuleSet"
545        | "ListReceiptRuleSets"
546        | "CloneReceiptRuleSet"
547        | "SetActiveReceiptRuleSet"
548        | "ReorderReceiptRuleSet"
549        | "CreateReceiptRule"
550        | "DeleteReceiptRule"
551        | "DescribeReceiptRule"
552        | "UpdateReceiptRule"
553        | "CreateReceiptFilter"
554        | "DeleteReceiptFilter"
555        | "ListReceiptFilters" => Some("ses".to_string()),
556        // SNS subscription handshake: the SubscribeURL / UnsubscribeUrl that SNS
557        // hands to HTTP/S and email subscribers are unsigned bare GETs (no auth
558        // header), so the service must be inferred from the action alone.
559        "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
560        _ => None,
561    }
562}
563
564/// Extract service name from the SigV4 Authorization header credential scope.
565fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
566    let auth = headers.get("authorization")?.to_str().ok()?;
567    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
568    Some(normalize_service_name(&info.service).to_string())
569}
570
571/// Map AWS service-name aliases that share path namespace and handlers
572/// to the canonical form used by fakecloud's service registry.
573///
574/// AWS uses `bedrock-runtime` in the SigV4 credential scope of runtime
575/// API calls (`InvokeModel`, `ApplyGuardrail`, etc.) but the REST paths
576/// (e.g. `POST /guardrail/{id}/version/{ver}/apply`) live under the same
577/// `BedrockService` handler that owns the control-plane `bedrock` paths.
578/// Without normalization, `detect_service` returns `None` for
579/// `bedrock-runtime` (not in `REST_JSON_SERVICES`), the central
580/// dispatcher falls back to API Gateway, and `/guardrail/...` 404s with
581/// `NotFoundException: Stage not found: guardrail`. See issue #1232.
582fn normalize_service_name(service: &str) -> &str {
583    match service {
584        "bedrock-runtime" => "bedrock",
585        // Real AWS API Gateway V2 SDK signs with `apigateway` as the SigV4
586        // service (per the model's `aws.api#service.endpointPrefix`), but
587        // tools driven by the Smithy service shape name (including our own
588        // conformance probe) may send `apigatewayv2`. Both refer to the
589        // same fakecloud service registry entry — the v2 handler is path-
590        // routed under `/v2/...` and the v1 handler under `/restapis/...`,
591        // both reachable behind the `apigateway` SigV4 service.
592        "apigatewayv2" => "apigateway",
593        // Amazon OpenSearch Service has no dedicated SigV4 signing scope: its
594        // SDK signs with `es` (the shared Elasticsearch Service scope), so a
595        // real OpenSearch request already arrives as `es` and needs no
596        // normalization. The conformance probe, however, signs with the
597        // Smithy service shape name `opensearch`; alias it to `es` so both the
598        // 2015 (Elasticsearch) and 2021 (OpenSearch) probes resolve to the one
599        // registry entry, which then routes on the URL path version prefix.
600        "opensearch" => "es",
601        other => other,
602    }
603}
604
605/// Parse form-encoded body into key-value pairs.
606pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
607    decode_form_urlencoded(body)
608}
609
610/// Flatten an awsJson request body into the flat `awsQuery` key form that
611/// query-protocol handlers consume.
612///
613/// CloudWatch is served by handlers written against the awsQuery flat-key map
614/// (`MetricData.member.1.MetricName`, `StatisticValues.Sum`,
615/// `Dimensions.member.2.Value`, ...). Its Smithy model also advertises
616/// `awsJson1_0`, so modern SDKs send a nested JSON body instead. Rather than
617/// duplicate every parser, we flatten the JSON into the same map the awsQuery
618/// handlers already read:
619///
620/// - object field `K` -> key `K` (or `<parent>.K` when nested in a struct)
621/// - array element `i` (1-based) -> `<K>.member.<i>` (matching the awsQuery
622///   list wire convention)
623/// - scalars -> their string form (numbers/booleans stringified)
624///
625/// A body that is not a JSON object yields an empty map.
626pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
627    let mut out = HashMap::new();
628    let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
629        return out;
630    };
631    if value.is_object() {
632        flatten_json_value("", &value, &mut out);
633    }
634    out
635}
636
637fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
638    match value {
639        serde_json::Value::Object(map) => {
640            for (k, v) in map {
641                let child = if prefix.is_empty() {
642                    k.clone()
643                } else {
644                    format!("{prefix}.{k}")
645                };
646                flatten_json_value(&child, v, out);
647            }
648        }
649        serde_json::Value::Array(items) => {
650            for (i, v) in items.iter().enumerate() {
651                let child = format!("{prefix}.member.{}", i + 1);
652                flatten_json_value(&child, v, out);
653            }
654        }
655        serde_json::Value::Null => {}
656        serde_json::Value::String(s) => {
657            out.insert(prefix.to_string(), s.clone());
658        }
659        serde_json::Value::Bool(b) => {
660            out.insert(prefix.to_string(), b.to_string());
661        }
662        serde_json::Value::Number(n) => {
663            out.insert(prefix.to_string(), n.to_string());
664        }
665    }
666}
667
668fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
669    let s = std::str::from_utf8(input).unwrap_or("");
670    let mut result = HashMap::new();
671    for pair in s.split('&') {
672        if pair.is_empty() {
673            continue;
674        }
675        let (key, value) = match pair.find('=') {
676            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
677            None => (pair, ""),
678        };
679        result.insert(url_decode(key), url_decode(value));
680    }
681    result
682}
683
684fn url_decode(input: &str) -> String {
685    let mut result = String::with_capacity(input.len());
686    let mut bytes = input.bytes();
687    while let Some(b) = bytes.next() {
688        match b {
689            b'+' => result.push(' '),
690            b'%' => {
691                let high = bytes.next().and_then(from_hex);
692                let low = bytes.next().and_then(from_hex);
693                if let (Some(h), Some(l)) = (high, low) {
694                    result.push((h << 4 | l) as char);
695                }
696            }
697            _ => result.push(b as char),
698        }
699    }
700    result
701}
702
703fn from_hex(b: u8) -> Option<u8> {
704    match b {
705        b'0'..=b'9' => Some(b - b'0'),
706        b'a'..=b'f' => Some(b - b'a' + 10),
707        b'A'..=b'F' => Some(b - b'A' + 10),
708        _ => None,
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    #[test]
717    fn parse_amz_target_events() {
718        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
719        assert_eq!(result.service, "events");
720        assert_eq!(result.action, "PutEvents");
721        assert_eq!(result.protocol, AwsProtocol::Json);
722    }
723
724    #[test]
725    fn parse_amz_target_ssm() {
726        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
727        assert_eq!(result.service, "ssm");
728        assert_eq!(result.action, "GetParameter");
729    }
730
731    #[test]
732    fn parse_amz_target_kinesis() {
733        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
734        assert_eq!(result.service, "kinesis");
735        assert_eq!(result.action, "ListStreams");
736        assert_eq!(result.protocol, AwsProtocol::Json);
737    }
738
739    #[test]
740    fn parse_query_body_basic() {
741        let body = Bytes::from(
742            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
743        );
744        let params = parse_query_body(&body);
745        assert_eq!(params.get("Action").unwrap(), "SendMessage");
746        assert_eq!(params.get("MessageBody").unwrap(), "hello");
747    }
748
749    #[test]
750    fn parse_query_body_empty_returns_empty_map() {
751        let body = Bytes::from("");
752        let params = parse_query_body(&body);
753        assert!(params.is_empty());
754    }
755
756    #[test]
757    fn parse_query_body_duplicate_keys_last_wins() {
758        let body = Bytes::from("key=a&key=b");
759        let params = parse_query_body(&body);
760        assert_eq!(params.get("key").unwrap(), "b");
761    }
762
763    #[test]
764    fn parse_query_body_single_key() {
765        let body = Bytes::from("key=value");
766        let params = parse_query_body(&body);
767        assert_eq!(params.get("key").unwrap(), "value");
768    }
769
770    #[test]
771    fn parse_amz_target_ecs() {
772        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
773        assert_eq!(result.service, "ecs");
774        assert_eq!(result.action, "ListClusters");
775        assert_eq!(result.protocol, AwsProtocol::Json);
776    }
777
778    #[test]
779    fn parse_amz_target_invalid_returns_none() {
780        assert!(parse_amz_target("NoDotHere").is_none());
781        assert!(parse_amz_target("").is_none());
782    }
783
784    #[test]
785    fn parse_amz_target_cloudwatch_json() {
786        // CloudWatch's awsJson1_0 target service shape.
787        let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
788        assert_eq!(result.service, "monitoring");
789        assert_eq!(result.action, "PutMetricData");
790        assert_eq!(result.protocol, AwsProtocol::Json);
791    }
792
793    #[test]
794    fn flatten_json_to_query_nested() {
795        let body = Bytes::from(
796            serde_json::json!({
797                "Namespace": "MyApp",
798                "MetricData": [{
799                    "MetricName": "Latency",
800                    "Value": 12.5,
801                    "StatisticValues": {"SampleCount": 3, "Sum": 10},
802                    "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
803                }]
804            })
805            .to_string(),
806        );
807        let flat = flatten_json_to_query(&body);
808        assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
809        assert_eq!(
810            flat.get("MetricData.member.1.MetricName").unwrap(),
811            "Latency"
812        );
813        assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
814        assert_eq!(
815            flat.get("MetricData.member.1.StatisticValues.SampleCount")
816                .unwrap(),
817            "3"
818        );
819        assert_eq!(
820            flat.get("MetricData.member.1.Dimensions.member.1.Name")
821                .unwrap(),
822            "Endpoint"
823        );
824        assert_eq!(
825            flat.get("MetricData.member.1.Dimensions.member.1.Value")
826                .unwrap(),
827            "/api"
828        );
829    }
830
831    #[test]
832    fn flatten_json_to_query_non_object_is_empty() {
833        assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
834        assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
835    }
836
837    #[test]
838    fn parse_amz_target_various_prefixes() {
839        assert_eq!(
840            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
841            "sqs"
842        );
843        assert_eq!(
844            parse_amz_target("AmazonSNS.Publish").unwrap().service,
845            "sns"
846        );
847        assert_eq!(
848            parse_amz_target("DynamoDB_20120810.GetItem")
849                .unwrap()
850                .service,
851            "dynamodb"
852        );
853        assert_eq!(
854            parse_amz_target("Logs_20140328.PutLogEvents")
855                .unwrap()
856                .service,
857            "logs"
858        );
859        assert_eq!(
860            parse_amz_target("secretsmanager.GetSecretValue")
861                .unwrap()
862                .service,
863            "secretsmanager"
864        );
865        assert_eq!(
866            parse_amz_target("TrentService.Encrypt").unwrap().service,
867            "kms"
868        );
869        assert_eq!(
870            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
871                .unwrap()
872                .service,
873            "cognito-idp"
874        );
875        assert_eq!(
876            parse_amz_target("AWSStepFunctions.StartExecution")
877                .unwrap()
878                .service,
879            "states"
880        );
881        assert_eq!(
882            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
883                .unwrap()
884                .service,
885            "organizations"
886        );
887        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
888    }
889
890    #[test]
891    fn infer_service_from_action_maps_sts() {
892        assert_eq!(
893            infer_service_from_action("AssumeRole").as_deref(),
894            Some("sts")
895        );
896        assert_eq!(
897            infer_service_from_action("GetCallerIdentity").as_deref(),
898            Some("sts")
899        );
900    }
901
902    #[test]
903    fn infer_service_from_action_maps_iam() {
904        assert_eq!(
905            infer_service_from_action("CreateUser").as_deref(),
906            Some("iam")
907        );
908        assert_eq!(
909            infer_service_from_action("ListRoles").as_deref(),
910            Some("iam")
911        );
912    }
913
914    #[test]
915    fn infer_service_from_action_maps_ses() {
916        assert_eq!(
917            infer_service_from_action("SendEmail").as_deref(),
918            Some("ses")
919        );
920        assert_eq!(
921            infer_service_from_action("ListIdentities").as_deref(),
922            Some("ses")
923        );
924    }
925
926    #[test]
927    fn infer_service_from_action_maps_sns_confirmation_flow() {
928        // SNS hands subscribers unsigned SubscribeURL / UnsubscribeUrl GETs,
929        // so the service must be inferred from the action alone.
930        assert_eq!(
931            infer_service_from_action("ConfirmSubscription").as_deref(),
932            Some("sns")
933        );
934        assert_eq!(
935            infer_service_from_action("Unsubscribe").as_deref(),
936            Some("sns")
937        );
938    }
939
940    #[test]
941    fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
942        // Mirror the bare GET an HTTP/S subscriber issues at the SubscribeURL:
943        // no Authorization header, bare-localhost Host, Action in the query.
944        let mut headers = HeaderMap::new();
945        headers.insert("host", "localhost:4566".parse().unwrap());
946        let mut query_params = HashMap::new();
947        query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
948        query_params.insert(
949            "TopicArn".to_string(),
950            "arn:aws:sns:us-east-1:000000000000:t".to_string(),
951        );
952        query_params.insert("Token".to_string(), "abc123".to_string());
953
954        let detected = detect_service(&headers, &query_params, &Bytes::new())
955            .expect("ConfirmSubscription must route to a service");
956        assert_eq!(detected.service, "sns");
957        assert_eq!(detected.action, "ConfirmSubscription");
958        assert_eq!(detected.protocol, AwsProtocol::Query);
959    }
960
961    #[test]
962    fn infer_service_from_action_unknown_returns_none() {
963        assert!(infer_service_from_action("NotARealAction").is_none());
964    }
965
966    #[test]
967    fn rest_protocol_for_returns_none_for_non_rest_service() {
968        assert!(rest_protocol_for("sqs").is_none());
969    }
970
971    #[test]
972    fn url_decode_handles_percent_and_plus() {
973        assert_eq!(url_decode("hello+world"), "hello world");
974        assert_eq!(url_decode("hello%20world"), "hello world");
975        assert_eq!(url_decode("100%25"), "100%");
976    }
977
978    #[test]
979    fn url_decode_ignores_malformed_percent() {
980        assert_eq!(url_decode("%ZZ"), "");
981    }
982
983    #[test]
984    fn from_hex_valid_digits() {
985        assert_eq!(from_hex(b'0'), Some(0));
986        assert_eq!(from_hex(b'9'), Some(9));
987        assert_eq!(from_hex(b'a'), Some(10));
988        assert_eq!(from_hex(b'F'), Some(15));
989    }
990
991    #[test]
992    fn from_hex_invalid_returns_none() {
993        assert!(from_hex(b'g').is_none());
994        assert!(from_hex(b' ').is_none());
995    }
996
997    #[test]
998    fn detect_service_via_amz_target() {
999        let mut headers = HeaderMap::new();
1000        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1001        let query = HashMap::new();
1002        let body = Bytes::new();
1003        let detected = detect_service(&headers, &query, &body).unwrap();
1004        assert_eq!(detected.service, "ssm");
1005        assert_eq!(detected.action, "GetParameter");
1006    }
1007
1008    #[test]
1009    fn detect_service_via_query_action_with_inferred_service() {
1010        let headers = HeaderMap::new();
1011        let mut query = HashMap::new();
1012        query.insert("Action".to_string(), "AssumeRole".to_string());
1013        let body = Bytes::new();
1014        let detected = detect_service(&headers, &query, &body).unwrap();
1015        assert_eq!(detected.service, "sts");
1016        assert_eq!(detected.action, "AssumeRole");
1017        assert_eq!(detected.protocol, AwsProtocol::Query);
1018    }
1019
1020    #[test]
1021    fn detect_service_via_form_body() {
1022        let headers = HeaderMap::new();
1023        let query = HashMap::new();
1024        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1025        let detected = detect_service(&headers, &query, &body).unwrap();
1026        assert_eq!(detected.service, "ses");
1027        assert_eq!(detected.action, "SendEmail");
1028    }
1029
1030    #[test]
1031    fn detect_service_via_sigv2_presigned() {
1032        let headers = HeaderMap::new();
1033        let mut query = HashMap::new();
1034        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1035        query.insert("Signature".to_string(), "sig".to_string());
1036        query.insert("Expires".to_string(), "1234567890".to_string());
1037        let body = Bytes::new();
1038        let detected = detect_service(&headers, &query, &body).unwrap();
1039        assert_eq!(detected.service, "s3");
1040        assert_eq!(detected.protocol, AwsProtocol::Rest);
1041    }
1042
1043    #[test]
1044    fn detect_service_via_sigv4_presigned_credential() {
1045        let headers = HeaderMap::new();
1046        let mut query = HashMap::new();
1047        query.insert(
1048            "X-Amz-Credential".to_string(),
1049            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1050        );
1051        let body = Bytes::new();
1052        let detected = detect_service(&headers, &query, &body).unwrap();
1053        assert_eq!(detected.service, "s3");
1054        assert_eq!(detected.protocol, AwsProtocol::Rest);
1055    }
1056
1057    #[test]
1058    fn detect_service_unknown_returns_none() {
1059        let headers = HeaderMap::new();
1060        let query = HashMap::new();
1061        let body = Bytes::new();
1062        assert!(detect_service(&headers, &query, &body).is_none());
1063    }
1064
1065    #[test]
1066    fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1067        // Real AWS API Gateway V2 SDK signs with `apigateway` per the
1068        // model's `endpointPrefix`, but Smithy-driven tooling (including
1069        // our conformance probe) sends `apigatewayv2`. Both routes resolve
1070        // to the same fakecloud service registry entry.
1071        assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1072    }
1073
1074    #[test]
1075    fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1076        // The bedrock-runtime credential scope shares path namespace with
1077        // the bedrock control plane (`POST /guardrail/{id}/version/{ver}/apply`
1078        // is implemented under BedrockService). Routing must resolve to
1079        // the bedrock service so the existing handlers run. See #1232.
1080        assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1081    }
1082
1083    #[test]
1084    fn normalize_service_name_passes_through_unaliased_services() {
1085        // Every service that isn't on the alias list must round-trip
1086        // unchanged — including the canonical bedrock name itself, so a
1087        // plain bedrock request takes the same code path it always has.
1088        assert_eq!(normalize_service_name("bedrock"), "bedrock");
1089        assert_eq!(normalize_service_name("s3"), "s3");
1090        assert_eq!(normalize_service_name("lambda"), "lambda");
1091        assert_eq!(normalize_service_name(""), "");
1092        assert_eq!(
1093            normalize_service_name("unknown-future-service"),
1094            "unknown-future-service"
1095        );
1096    }
1097
1098    #[test]
1099    fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1100        // SigV4 auth header carries `bedrock-runtime` in the credential
1101        // scope; dispatcher must route to the bedrock service handler so
1102        // `/guardrail/...` lands on `BedrockService` instead of falling
1103        // through to API Gateway.
1104        let mut headers = HeaderMap::new();
1105        headers.insert(
1106            "authorization",
1107            "AWS4-HMAC-SHA256 \
1108             Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1109             SignedHeaders=host, Signature=abc"
1110                .parse()
1111                .unwrap(),
1112        );
1113        let query = HashMap::new();
1114        let body = Bytes::new();
1115        let detected = detect_service(&headers, &query, &body).unwrap();
1116        assert_eq!(detected.service, "bedrock");
1117        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1118    }
1119
1120    #[test]
1121    fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1122        // Same alias normalization on the presigned-URL path: a request
1123        // signed with bedrock-runtime in the X-Amz-Credential query param
1124        // must still resolve to the bedrock service handler.
1125        let headers = HeaderMap::new();
1126        let mut query = HashMap::new();
1127        query.insert(
1128            "X-Amz-Credential".to_string(),
1129            "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1130        );
1131        let body = Bytes::new();
1132        let detected = detect_service(&headers, &query, &body).unwrap();
1133        assert_eq!(detected.service, "bedrock");
1134        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1135    }
1136
1137    #[test]
1138    fn parse_routing_host_localstack_basic() {
1139        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1140        assert_eq!(h.service, "sqs");
1141        assert_eq!(h.region, "us-east-1");
1142        assert!(h.bucket.is_none());
1143    }
1144
1145    #[test]
1146    fn parse_routing_host_localstack_with_port() {
1147        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1148        assert_eq!(h.service, "lambda");
1149        assert_eq!(h.region, "eu-west-1");
1150        assert!(h.bucket.is_none());
1151    }
1152
1153    #[test]
1154    fn parse_routing_host_case_insensitive() {
1155        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1156        assert_eq!(h.service, "sqs");
1157        assert_eq!(h.region, "us-east-1");
1158
1159        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1160        assert_eq!(h.service, "lambda");
1161        assert_eq!(h.region, "us-east-1");
1162    }
1163
1164    #[test]
1165    fn parse_routing_host_localstack_s3_virtual_hosted() {
1166        let h =
1167            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1168        assert_eq!(h.service, "s3");
1169        assert_eq!(h.region, "us-east-1");
1170        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1171    }
1172
1173    #[test]
1174    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1175        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1176        assert_eq!(h.service, "s3");
1177        assert_eq!(h.region, "us-east-1");
1178        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1179    }
1180
1181    #[test]
1182    fn parse_routing_host_aws_service_region() {
1183        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1184        assert_eq!(h.service, "sqs");
1185        assert_eq!(h.region, "us-east-1");
1186        assert!(h.bucket.is_none());
1187
1188        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1189        assert_eq!(h.service, "dynamodb");
1190        assert_eq!(h.region, "eu-west-2");
1191    }
1192
1193    #[test]
1194    fn parse_routing_host_aws_s3_path_style_modern() {
1195        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1196        assert_eq!(h.service, "s3");
1197        assert_eq!(h.region, "us-east-1");
1198        assert!(h.bucket.is_none());
1199    }
1200
1201    #[test]
1202    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1203        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1204        assert_eq!(h.service, "s3");
1205        assert_eq!(h.region, "us-east-1");
1206        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1207    }
1208
1209    #[test]
1210    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1211        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1212        assert_eq!(h.service, "s3");
1213        assert_eq!(h.region, "us-east-1");
1214        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1215    }
1216
1217    #[test]
1218    fn parse_routing_host_aws_s3_legacy_global() {
1219        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
1220        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
1221        let h = parse_routing_host("s3.amazonaws.com").unwrap();
1222        assert_eq!(h.service, "s3");
1223        assert_eq!(h.region, "us-east-1");
1224        assert!(h.bucket.is_none());
1225
1226        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1227        assert_eq!(h.service, "s3");
1228        assert_eq!(h.region, "us-east-1");
1229        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1230    }
1231
1232    #[test]
1233    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1234        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
1235        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
1236        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1237        assert_eq!(h.service, "s3");
1238        assert_eq!(h.region, "us-east-1");
1239        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1240    }
1241
1242    #[test]
1243    fn parse_routing_host_aws_s3_dash_separated() {
1244        // Older dash-separated form still served by AWS.
1245        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1246        assert_eq!(h.service, "s3");
1247        assert_eq!(h.region, "us-west-2");
1248        assert!(h.bucket.is_none());
1249
1250        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1251        assert_eq!(h.service, "s3");
1252        assert_eq!(h.region, "us-west-2");
1253        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1254    }
1255
1256    #[test]
1257    fn parse_routing_host_rejects_plain_localhost() {
1258        assert!(parse_routing_host("localhost:4566").is_none());
1259        assert!(parse_routing_host("127.0.0.1:4566").is_none());
1260    }
1261
1262    #[test]
1263    fn parse_routing_host_rejects_unknown_suffix() {
1264        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1265        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1266    }
1267
1268    #[test]
1269    fn parse_routing_host_empty_and_malformed_rejected() {
1270        assert!(parse_routing_host("").is_none());
1271        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1272        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1273        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1274        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1275        assert!(parse_routing_host(".amazonaws.com").is_none());
1276        assert!(parse_routing_host("amazonaws.com").is_none());
1277    }
1278
1279    #[test]
1280    fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1281        // A single-label "s3-accesspoint" host has < 2 labels, so the
1282        // virtual-hosted `len() - 2` slice would underflow and panic without
1283        // the length guard. It must be rejected, not crash the router.
1284        assert!(parse_routing_host("s3-accesspoint").is_none());
1285    }
1286
1287    #[test]
1288    fn detect_service_via_host_for_rest_service() {
1289        let mut headers = HeaderMap::new();
1290        headers.insert(
1291            "host",
1292            "s3.us-east-1.localhost.localstack.cloud:4566"
1293                .parse()
1294                .unwrap(),
1295        );
1296        let query = HashMap::new();
1297        let body = Bytes::new();
1298        let detected = detect_service(&headers, &query, &body).unwrap();
1299        assert_eq!(detected.service, "s3");
1300        assert_eq!(detected.protocol, AwsProtocol::Rest);
1301    }
1302
1303    #[test]
1304    fn detect_service_via_host_for_rest_json_service() {
1305        let mut headers = HeaderMap::new();
1306        headers.insert(
1307            "host",
1308            "lambda.us-east-1.localhost.localstack.cloud:4566"
1309                .parse()
1310                .unwrap(),
1311        );
1312        let query = HashMap::new();
1313        let body = Bytes::new();
1314        let detected = detect_service(&headers, &query, &body).unwrap();
1315        assert_eq!(detected.service, "lambda");
1316        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1317    }
1318
1319    #[test]
1320    fn detect_service_via_host_plus_query_action() {
1321        let mut headers = HeaderMap::new();
1322        headers.insert(
1323            "host",
1324            "sqs.us-east-1.localhost.localstack.cloud:4566"
1325                .parse()
1326                .unwrap(),
1327        );
1328        let mut query = HashMap::new();
1329        query.insert("Action".to_string(), "ListQueues".to_string());
1330        let body = Bytes::new();
1331        let detected = detect_service(&headers, &query, &body).unwrap();
1332        assert_eq!(detected.service, "sqs");
1333        assert_eq!(detected.action, "ListQueues");
1334        assert_eq!(detected.protocol, AwsProtocol::Query);
1335    }
1336
1337    #[test]
1338    fn detect_service_sigv4_wins_over_host() {
1339        let mut headers = HeaderMap::new();
1340        headers.insert(
1341            "authorization",
1342            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1343             SignedHeaders=host, Signature=abc"
1344                .parse()
1345                .unwrap(),
1346        );
1347        headers.insert(
1348            "host",
1349            "lambda.us-east-1.localhost.localstack.cloud:4566"
1350                .parse()
1351                .unwrap(),
1352        );
1353        let query = HashMap::new();
1354        let body = Bytes::new();
1355        let detected = detect_service(&headers, &query, &body).unwrap();
1356        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
1357        assert_eq!(detected.service, "s3");
1358        assert_eq!(detected.protocol, AwsProtocol::Rest);
1359    }
1360
1361    #[test]
1362    fn detect_service_host_for_virtual_hosted_s3() {
1363        let mut headers = HeaderMap::new();
1364        headers.insert(
1365            "host",
1366            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1367                .parse()
1368                .unwrap(),
1369        );
1370        let query = HashMap::new();
1371        let body = Bytes::new();
1372        let detected = detect_service(&headers, &query, &body).unwrap();
1373        assert_eq!(detected.service, "s3");
1374        assert_eq!(detected.protocol, AwsProtocol::Rest);
1375    }
1376}