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