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