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