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