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        "AnyScaleFrontendService" => "application-autoscaling",
469        // Match the WAFv2 target version exactly so legacy WAF Classic
470        // (`AWSWAF_*` without the `_20190729` suffix) doesn't get routed here.
471        "AWSWAF_20190729" => "wafv2",
472        "AmazonAthena" => "athena",
473        s if s.starts_with("Firehose_") => "firehose",
474        "AWSGlue" => "glue",
475        "CloudApiService" => "cloudcontrolapi",
476        "ResourceGroupsTaggingAPI_20170126" => "tagging",
477        "AmazonMemoryDB" => "memorydb",
478        // Cloud Map (servicediscovery): awsJson1.1, target prefix carries the
479        // dated Route53 Auto Naming service version.
480        "Route53AutoNaming_v20170314" => "servicediscovery",
481        // Database Migration Service: awsJson1.1.
482        "AmazonDMSv20160101" => "dms",
483        // CloudTrail: awsJson1.1.
484        // aws-sdk-go-v2 / smithy clients (and terraform) send the short shape
485        // name; aws-sdk-go-v1 sends the fully-qualified form. Accept both.
486        "CloudTrail_20131101" => "cloudtrail",
487        "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
488        // Cost Explorer: awsJson1.1. aws-sdk / smithy clients (and terraform)
489        // send the short service-shape name; older clients may send the
490        // fully-qualified form. Accept both.
491        "AWSInsightsIndexService" => "ce",
492        "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
493        // Transfer Family: awsJson1.1.
494        "TransferService" => "transfer",
495        // AWS CodeBuild: awsJson1.1, target prefix is the dated service shape.
496        "CodeBuild_20161006" => "codebuild",
497        // AWS CodeCommit: awsJson1.1, target prefix is the dated service shape.
498        "CodeCommit_20150413" => "codecommit",
499        // IAM Identity Center Identity Store: awsJson1.1.
500        "AWSIdentityStore" => "identitystore",
501        // IAM Identity Center SSO Admin: awsJson1.1.
502        "SWBExternalService" => "sso",
503        // Verified Permissions: awsJson1.0.
504        "VerifiedPermissions" => "verifiedpermissions",
505        // CodeConnections (successor to CodeStar Connections): awsJson1.0.
506        "CodeConnections_20231201" => "codeconnections",
507        // Legacy CodeStar Connections API (same operations as CodeConnections);
508        // the terraform `aws_codestarconnections_connection` resource still
509        // signs with this dated prefix (note the lowercase `connections`, as
510        // emitted by the aws-sdk-go-v2 codestarconnections client), so route it
511        // to the same handler.
512        "CodeStar_connections_20191201" => "codeconnections",
513        // AWS CodeDeploy: awsJson1.1, target prefix is the dated service shape.
514        "CodeDeploy_20141006" => "codedeploy",
515        // AWS CodePipeline: awsJson1.1, target prefix is the dated service shape.
516        "CodePipeline_20150709" => "codepipeline",
517        // CloudWatch advertises awsJson1_0 (target service shape
518        // `GraniteServiceVersion20100801`) alongside the legacy awsQuery
519        // protocol. Newer SDKs (aws-sdk-rust / js-v3 / go-v2) POST with
520        // `X-Amz-Target: GraniteServiceVersion20100801.<Operation>` and a JSON
521        // body. The service registry key is `monitoring`.
522        s if s.starts_with("GraniteServiceVersion") => "monitoring",
523        _ => return None,
524    };
525
526    Some(DetectedRequest {
527        service: service.to_string(),
528        action: action.to_string(),
529        protocol: AwsProtocol::Json,
530    })
531}
532
533/// Returns the REST protocol variant for a service, or None if not a REST service.
534fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
535    if REST_XML_SERVICES.contains(&service) {
536        Some(AwsProtocol::Rest)
537    } else if REST_JSON_SERVICES.contains(&service) {
538        Some(AwsProtocol::RestJson)
539    } else {
540        None
541    }
542}
543
544/// Infer service from the action name when no SigV4 auth is present.
545/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
546/// do not require authentication and won't have an Authorization header.
547fn infer_service_from_action(action: &str) -> Option<String> {
548    match action {
549        "AssumeRole"
550        | "AssumeRoleWithSAML"
551        | "AssumeRoleWithWebIdentity"
552        | "GetCallerIdentity"
553        | "GetSessionToken"
554        | "GetFederationToken"
555        | "GetAccessKeyInfo"
556        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
557        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
558        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
559        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
560        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
561        // SES v1 (Query protocol)
562        "VerifyEmailIdentity"
563        | "VerifyDomainIdentity"
564        | "VerifyDomainDkim"
565        | "ListIdentities"
566        | "GetIdentityVerificationAttributes"
567        | "GetIdentityDkimAttributes"
568        | "DeleteIdentity"
569        | "SetIdentityDkimEnabled"
570        | "SetIdentityNotificationTopic"
571        | "SetIdentityFeedbackForwardingEnabled"
572        | "GetIdentityNotificationAttributes"
573        | "GetIdentityMailFromDomainAttributes"
574        | "SetIdentityMailFromDomain"
575        | "SendEmail"
576        | "SendRawEmail"
577        | "SendTemplatedEmail"
578        | "SendBulkTemplatedEmail"
579        | "CreateTemplate"
580        | "GetTemplate"
581        | "ListTemplates"
582        | "DeleteTemplate"
583        | "UpdateTemplate"
584        | "CreateConfigurationSet"
585        | "DeleteConfigurationSet"
586        | "DescribeConfigurationSet"
587        | "ListConfigurationSets"
588        | "CreateConfigurationSetEventDestination"
589        | "UpdateConfigurationSetEventDestination"
590        | "DeleteConfigurationSetEventDestination"
591        | "GetSendQuota"
592        | "GetSendStatistics"
593        | "GetAccountSendingEnabled"
594        | "CreateReceiptRuleSet"
595        | "DeleteReceiptRuleSet"
596        | "DescribeReceiptRuleSet"
597        | "ListReceiptRuleSets"
598        | "CloneReceiptRuleSet"
599        | "SetActiveReceiptRuleSet"
600        | "ReorderReceiptRuleSet"
601        | "CreateReceiptRule"
602        | "DeleteReceiptRule"
603        | "DescribeReceiptRule"
604        | "UpdateReceiptRule"
605        | "CreateReceiptFilter"
606        | "DeleteReceiptFilter"
607        | "ListReceiptFilters" => Some("ses".to_string()),
608        // SNS subscription handshake: the SubscribeURL / UnsubscribeUrl that SNS
609        // hands to HTTP/S and email subscribers are unsigned bare GETs (no auth
610        // header), so the service must be inferred from the action alone.
611        "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
612        _ => None,
613    }
614}
615
616/// Extract service name from the SigV4 Authorization header credential scope.
617fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
618    let auth = headers.get("authorization")?.to_str().ok()?;
619    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
620    Some(normalize_service_name(&info.service).to_string())
621}
622
623/// Map AWS service-name aliases that share path namespace and handlers
624/// to the canonical form used by fakecloud's service registry.
625///
626/// AWS uses `bedrock-runtime` in the SigV4 credential scope of runtime
627/// API calls (`InvokeModel`, `ApplyGuardrail`, etc.) but the REST paths
628/// (e.g. `POST /guardrail/{id}/version/{ver}/apply`) live under the same
629/// `BedrockService` handler that owns the control-plane `bedrock` paths.
630/// Without normalization, `detect_service` returns `None` for
631/// `bedrock-runtime` (not in `REST_JSON_SERVICES`), the central
632/// dispatcher falls back to API Gateway, and `/guardrail/...` 404s with
633/// `NotFoundException: Stage not found: guardrail`. See issue #1232.
634fn normalize_service_name(service: &str) -> &str {
635    match service {
636        "bedrock-runtime" => "bedrock",
637        // Real AWS API Gateway V2 SDK signs with `apigateway` as the SigV4
638        // service (per the model's `aws.api#service.endpointPrefix`), but
639        // tools driven by the Smithy service shape name (including our own
640        // conformance probe) may send `apigatewayv2`. Both refer to the
641        // same fakecloud service registry entry — the v2 handler is path-
642        // routed under `/v2/...` and the v1 handler under `/restapis/...`,
643        // both reachable behind the `apigateway` SigV4 service.
644        "apigatewayv2" => "apigateway",
645        // Amazon OpenSearch Service has no dedicated SigV4 signing scope: its
646        // SDK signs with `es` (the shared Elasticsearch Service scope), so a
647        // real OpenSearch request already arrives as `es` and needs no
648        // normalization. The conformance probe, however, signs with the
649        // Smithy service shape name `opensearch`; alias it to `es` so both the
650        // 2015 (Elasticsearch) and 2021 (OpenSearch) probes resolve to the one
651        // registry entry, which then routes on the URL path version prefix.
652        "opensearch" => "es",
653        // AWS AppConfig Data signs with the shared `appconfig` scope, so a real
654        // request already arrives as `appconfig`. The conformance probe signs
655        // the data-plane operations with the Smithy service shape name
656        // `appconfigdata`; alias it to `appconfig` so both model-services
657        // resolve to the one registry entry, which routes on the URL path.
658        "appconfigdata" => "appconfig",
659        other => other,
660    }
661}
662
663/// Parse form-encoded body into key-value pairs.
664pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
665    decode_form_urlencoded(body)
666}
667
668/// Flatten an awsJson request body into the flat `awsQuery` key form that
669/// query-protocol handlers consume.
670///
671/// CloudWatch is served by handlers written against the awsQuery flat-key map
672/// (`MetricData.member.1.MetricName`, `StatisticValues.Sum`,
673/// `Dimensions.member.2.Value`, ...). Its Smithy model also advertises
674/// `awsJson1_0`, so modern SDKs send a nested JSON body instead. Rather than
675/// duplicate every parser, we flatten the JSON into the same map the awsQuery
676/// handlers already read:
677///
678/// - object field `K` -> key `K` (or `<parent>.K` when nested in a struct)
679/// - array element `i` (1-based) -> `<K>.member.<i>` (matching the awsQuery
680///   list wire convention)
681/// - scalars -> their string form (numbers/booleans stringified)
682///
683/// A body that is not a JSON object yields an empty map.
684pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
685    let mut out = HashMap::new();
686    let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
687        return out;
688    };
689    if value.is_object() {
690        flatten_json_value("", &value, &mut out);
691    }
692    out
693}
694
695fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
696    match value {
697        serde_json::Value::Object(map) => {
698            for (k, v) in map {
699                let child = if prefix.is_empty() {
700                    k.clone()
701                } else {
702                    format!("{prefix}.{k}")
703                };
704                flatten_json_value(&child, v, out);
705            }
706        }
707        serde_json::Value::Array(items) => {
708            for (i, v) in items.iter().enumerate() {
709                let child = format!("{prefix}.member.{}", i + 1);
710                flatten_json_value(&child, v, out);
711            }
712        }
713        serde_json::Value::Null => {}
714        serde_json::Value::String(s) => {
715            out.insert(prefix.to_string(), s.clone());
716        }
717        serde_json::Value::Bool(b) => {
718            out.insert(prefix.to_string(), b.to_string());
719        }
720        serde_json::Value::Number(n) => {
721            out.insert(prefix.to_string(), n.to_string());
722        }
723    }
724}
725
726fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
727    let s = std::str::from_utf8(input).unwrap_or("");
728    let mut result = HashMap::new();
729    for pair in s.split('&') {
730        if pair.is_empty() {
731            continue;
732        }
733        let (key, value) = match pair.find('=') {
734            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
735            None => (pair, ""),
736        };
737        result.insert(url_decode(key), url_decode(value));
738    }
739    result
740}
741
742fn url_decode(input: &str) -> String {
743    let mut result = String::with_capacity(input.len());
744    let mut bytes = input.bytes();
745    while let Some(b) = bytes.next() {
746        match b {
747            b'+' => result.push(' '),
748            b'%' => {
749                let high = bytes.next().and_then(from_hex);
750                let low = bytes.next().and_then(from_hex);
751                if let (Some(h), Some(l)) = (high, low) {
752                    result.push((h << 4 | l) as char);
753                }
754            }
755            _ => result.push(b as char),
756        }
757    }
758    result
759}
760
761fn from_hex(b: u8) -> Option<u8> {
762    match b {
763        b'0'..=b'9' => Some(b - b'0'),
764        b'a'..=b'f' => Some(b - b'a' + 10),
765        b'A'..=b'F' => Some(b - b'A' + 10),
766        _ => None,
767    }
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773
774    #[test]
775    fn parse_amz_target_events() {
776        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
777        assert_eq!(result.service, "events");
778        assert_eq!(result.action, "PutEvents");
779        assert_eq!(result.protocol, AwsProtocol::Json);
780    }
781
782    #[test]
783    fn parse_amz_target_ssm() {
784        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
785        assert_eq!(result.service, "ssm");
786        assert_eq!(result.action, "GetParameter");
787    }
788
789    #[test]
790    fn parse_amz_target_kinesis() {
791        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
792        assert_eq!(result.service, "kinesis");
793        assert_eq!(result.action, "ListStreams");
794        assert_eq!(result.protocol, AwsProtocol::Json);
795    }
796
797    #[test]
798    fn parse_query_body_basic() {
799        let body = Bytes::from(
800            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
801        );
802        let params = parse_query_body(&body);
803        assert_eq!(params.get("Action").unwrap(), "SendMessage");
804        assert_eq!(params.get("MessageBody").unwrap(), "hello");
805    }
806
807    #[test]
808    fn parse_query_body_empty_returns_empty_map() {
809        let body = Bytes::from("");
810        let params = parse_query_body(&body);
811        assert!(params.is_empty());
812    }
813
814    #[test]
815    fn parse_query_body_duplicate_keys_last_wins() {
816        let body = Bytes::from("key=a&key=b");
817        let params = parse_query_body(&body);
818        assert_eq!(params.get("key").unwrap(), "b");
819    }
820
821    #[test]
822    fn parse_query_body_single_key() {
823        let body = Bytes::from("key=value");
824        let params = parse_query_body(&body);
825        assert_eq!(params.get("key").unwrap(), "value");
826    }
827
828    #[test]
829    fn parse_amz_target_ecs() {
830        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
831        assert_eq!(result.service, "ecs");
832        assert_eq!(result.action, "ListClusters");
833        assert_eq!(result.protocol, AwsProtocol::Json);
834    }
835
836    #[test]
837    fn parse_amz_target_invalid_returns_none() {
838        assert!(parse_amz_target("NoDotHere").is_none());
839        assert!(parse_amz_target("").is_none());
840    }
841
842    #[test]
843    fn parse_amz_target_cloudwatch_json() {
844        // CloudWatch's awsJson1_0 target service shape.
845        let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
846        assert_eq!(result.service, "monitoring");
847        assert_eq!(result.action, "PutMetricData");
848        assert_eq!(result.protocol, AwsProtocol::Json);
849    }
850
851    #[test]
852    fn flatten_json_to_query_nested() {
853        let body = Bytes::from(
854            serde_json::json!({
855                "Namespace": "MyApp",
856                "MetricData": [{
857                    "MetricName": "Latency",
858                    "Value": 12.5,
859                    "StatisticValues": {"SampleCount": 3, "Sum": 10},
860                    "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
861                }]
862            })
863            .to_string(),
864        );
865        let flat = flatten_json_to_query(&body);
866        assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
867        assert_eq!(
868            flat.get("MetricData.member.1.MetricName").unwrap(),
869            "Latency"
870        );
871        assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
872        assert_eq!(
873            flat.get("MetricData.member.1.StatisticValues.SampleCount")
874                .unwrap(),
875            "3"
876        );
877        assert_eq!(
878            flat.get("MetricData.member.1.Dimensions.member.1.Name")
879                .unwrap(),
880            "Endpoint"
881        );
882        assert_eq!(
883            flat.get("MetricData.member.1.Dimensions.member.1.Value")
884                .unwrap(),
885            "/api"
886        );
887    }
888
889    #[test]
890    fn flatten_json_to_query_non_object_is_empty() {
891        assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
892        assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
893    }
894
895    #[test]
896    fn parse_amz_target_various_prefixes() {
897        assert_eq!(
898            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
899            "sqs"
900        );
901        assert_eq!(
902            parse_amz_target("AmazonSNS.Publish").unwrap().service,
903            "sns"
904        );
905        assert_eq!(
906            parse_amz_target("DynamoDB_20120810.GetItem")
907                .unwrap()
908                .service,
909            "dynamodb"
910        );
911        assert_eq!(
912            parse_amz_target("Logs_20140328.PutLogEvents")
913                .unwrap()
914                .service,
915            "logs"
916        );
917        assert_eq!(
918            parse_amz_target("secretsmanager.GetSecretValue")
919                .unwrap()
920                .service,
921            "secretsmanager"
922        );
923        assert_eq!(
924            parse_amz_target("TrentService.Encrypt").unwrap().service,
925            "kms"
926        );
927        assert_eq!(
928            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
929                .unwrap()
930                .service,
931            "cognito-idp"
932        );
933        assert_eq!(
934            parse_amz_target("AWSStepFunctions.StartExecution")
935                .unwrap()
936                .service,
937            "states"
938        );
939        assert_eq!(
940            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
941                .unwrap()
942                .service,
943            "organizations"
944        );
945        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
946    }
947
948    #[test]
949    fn infer_service_from_action_maps_sts() {
950        assert_eq!(
951            infer_service_from_action("AssumeRole").as_deref(),
952            Some("sts")
953        );
954        assert_eq!(
955            infer_service_from_action("GetCallerIdentity").as_deref(),
956            Some("sts")
957        );
958    }
959
960    #[test]
961    fn infer_service_from_action_maps_iam() {
962        assert_eq!(
963            infer_service_from_action("CreateUser").as_deref(),
964            Some("iam")
965        );
966        assert_eq!(
967            infer_service_from_action("ListRoles").as_deref(),
968            Some("iam")
969        );
970    }
971
972    #[test]
973    fn infer_service_from_action_maps_ses() {
974        assert_eq!(
975            infer_service_from_action("SendEmail").as_deref(),
976            Some("ses")
977        );
978        assert_eq!(
979            infer_service_from_action("ListIdentities").as_deref(),
980            Some("ses")
981        );
982    }
983
984    #[test]
985    fn infer_service_from_action_maps_sns_confirmation_flow() {
986        // SNS hands subscribers unsigned SubscribeURL / UnsubscribeUrl GETs,
987        // so the service must be inferred from the action alone.
988        assert_eq!(
989            infer_service_from_action("ConfirmSubscription").as_deref(),
990            Some("sns")
991        );
992        assert_eq!(
993            infer_service_from_action("Unsubscribe").as_deref(),
994            Some("sns")
995        );
996    }
997
998    #[test]
999    fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1000        // Mirror the bare GET an HTTP/S subscriber issues at the SubscribeURL:
1001        // no Authorization header, bare-localhost Host, Action in the query.
1002        let mut headers = HeaderMap::new();
1003        headers.insert("host", "localhost:4566".parse().unwrap());
1004        let mut query_params = HashMap::new();
1005        query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1006        query_params.insert(
1007            "TopicArn".to_string(),
1008            "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1009        );
1010        query_params.insert("Token".to_string(), "abc123".to_string());
1011
1012        let detected = detect_service(&headers, &query_params, &Bytes::new())
1013            .expect("ConfirmSubscription must route to a service");
1014        assert_eq!(detected.service, "sns");
1015        assert_eq!(detected.action, "ConfirmSubscription");
1016        assert_eq!(detected.protocol, AwsProtocol::Query);
1017    }
1018
1019    #[test]
1020    fn infer_service_from_action_unknown_returns_none() {
1021        assert!(infer_service_from_action("NotARealAction").is_none());
1022    }
1023
1024    #[test]
1025    fn rest_protocol_for_returns_none_for_non_rest_service() {
1026        assert!(rest_protocol_for("sqs").is_none());
1027    }
1028
1029    #[test]
1030    fn url_decode_handles_percent_and_plus() {
1031        assert_eq!(url_decode("hello+world"), "hello world");
1032        assert_eq!(url_decode("hello%20world"), "hello world");
1033        assert_eq!(url_decode("100%25"), "100%");
1034    }
1035
1036    #[test]
1037    fn url_decode_ignores_malformed_percent() {
1038        assert_eq!(url_decode("%ZZ"), "");
1039    }
1040
1041    #[test]
1042    fn from_hex_valid_digits() {
1043        assert_eq!(from_hex(b'0'), Some(0));
1044        assert_eq!(from_hex(b'9'), Some(9));
1045        assert_eq!(from_hex(b'a'), Some(10));
1046        assert_eq!(from_hex(b'F'), Some(15));
1047    }
1048
1049    #[test]
1050    fn from_hex_invalid_returns_none() {
1051        assert!(from_hex(b'g').is_none());
1052        assert!(from_hex(b' ').is_none());
1053    }
1054
1055    #[test]
1056    fn detect_service_via_amz_target() {
1057        let mut headers = HeaderMap::new();
1058        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1059        let query = HashMap::new();
1060        let body = Bytes::new();
1061        let detected = detect_service(&headers, &query, &body).unwrap();
1062        assert_eq!(detected.service, "ssm");
1063        assert_eq!(detected.action, "GetParameter");
1064    }
1065
1066    #[test]
1067    fn detect_service_via_query_action_with_inferred_service() {
1068        let headers = HeaderMap::new();
1069        let mut query = HashMap::new();
1070        query.insert("Action".to_string(), "AssumeRole".to_string());
1071        let body = Bytes::new();
1072        let detected = detect_service(&headers, &query, &body).unwrap();
1073        assert_eq!(detected.service, "sts");
1074        assert_eq!(detected.action, "AssumeRole");
1075        assert_eq!(detected.protocol, AwsProtocol::Query);
1076    }
1077
1078    #[test]
1079    fn detect_service_via_form_body() {
1080        let headers = HeaderMap::new();
1081        let query = HashMap::new();
1082        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1083        let detected = detect_service(&headers, &query, &body).unwrap();
1084        assert_eq!(detected.service, "ses");
1085        assert_eq!(detected.action, "SendEmail");
1086    }
1087
1088    #[test]
1089    fn detect_service_via_sigv2_presigned() {
1090        let headers = HeaderMap::new();
1091        let mut query = HashMap::new();
1092        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1093        query.insert("Signature".to_string(), "sig".to_string());
1094        query.insert("Expires".to_string(), "1234567890".to_string());
1095        let body = Bytes::new();
1096        let detected = detect_service(&headers, &query, &body).unwrap();
1097        assert_eq!(detected.service, "s3");
1098        assert_eq!(detected.protocol, AwsProtocol::Rest);
1099    }
1100
1101    #[test]
1102    fn detect_service_via_sigv4_presigned_credential() {
1103        let headers = HeaderMap::new();
1104        let mut query = HashMap::new();
1105        query.insert(
1106            "X-Amz-Credential".to_string(),
1107            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1108        );
1109        let body = Bytes::new();
1110        let detected = detect_service(&headers, &query, &body).unwrap();
1111        assert_eq!(detected.service, "s3");
1112        assert_eq!(detected.protocol, AwsProtocol::Rest);
1113    }
1114
1115    #[test]
1116    fn detect_service_unknown_returns_none() {
1117        let headers = HeaderMap::new();
1118        let query = HashMap::new();
1119        let body = Bytes::new();
1120        assert!(detect_service(&headers, &query, &body).is_none());
1121    }
1122
1123    #[test]
1124    fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1125        // Real AWS API Gateway V2 SDK signs with `apigateway` per the
1126        // model's `endpointPrefix`, but Smithy-driven tooling (including
1127        // our conformance probe) sends `apigatewayv2`. Both routes resolve
1128        // to the same fakecloud service registry entry.
1129        assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1130    }
1131
1132    #[test]
1133    fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1134        // The bedrock-runtime credential scope shares path namespace with
1135        // the bedrock control plane (`POST /guardrail/{id}/version/{ver}/apply`
1136        // is implemented under BedrockService). Routing must resolve to
1137        // the bedrock service so the existing handlers run. See #1232.
1138        assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1139    }
1140
1141    #[test]
1142    fn normalize_service_name_passes_through_unaliased_services() {
1143        // Every service that isn't on the alias list must round-trip
1144        // unchanged — including the canonical bedrock name itself, so a
1145        // plain bedrock request takes the same code path it always has.
1146        assert_eq!(normalize_service_name("bedrock"), "bedrock");
1147        assert_eq!(normalize_service_name("s3"), "s3");
1148        assert_eq!(normalize_service_name("lambda"), "lambda");
1149        assert_eq!(normalize_service_name(""), "");
1150        assert_eq!(
1151            normalize_service_name("unknown-future-service"),
1152            "unknown-future-service"
1153        );
1154    }
1155
1156    #[test]
1157    fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1158        // SigV4 auth header carries `bedrock-runtime` in the credential
1159        // scope; dispatcher must route to the bedrock service handler so
1160        // `/guardrail/...` lands on `BedrockService` instead of falling
1161        // through to API Gateway.
1162        let mut headers = HeaderMap::new();
1163        headers.insert(
1164            "authorization",
1165            "AWS4-HMAC-SHA256 \
1166             Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1167             SignedHeaders=host, Signature=abc"
1168                .parse()
1169                .unwrap(),
1170        );
1171        let query = HashMap::new();
1172        let body = Bytes::new();
1173        let detected = detect_service(&headers, &query, &body).unwrap();
1174        assert_eq!(detected.service, "bedrock");
1175        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1176    }
1177
1178    #[test]
1179    fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1180        // Same alias normalization on the presigned-URL path: a request
1181        // signed with bedrock-runtime in the X-Amz-Credential query param
1182        // must still resolve to the bedrock service handler.
1183        let headers = HeaderMap::new();
1184        let mut query = HashMap::new();
1185        query.insert(
1186            "X-Amz-Credential".to_string(),
1187            "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1188        );
1189        let body = Bytes::new();
1190        let detected = detect_service(&headers, &query, &body).unwrap();
1191        assert_eq!(detected.service, "bedrock");
1192        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1193    }
1194
1195    #[test]
1196    fn parse_routing_host_localstack_basic() {
1197        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1198        assert_eq!(h.service, "sqs");
1199        assert_eq!(h.region, "us-east-1");
1200        assert!(h.bucket.is_none());
1201    }
1202
1203    #[test]
1204    fn parse_routing_host_localstack_with_port() {
1205        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1206        assert_eq!(h.service, "lambda");
1207        assert_eq!(h.region, "eu-west-1");
1208        assert!(h.bucket.is_none());
1209    }
1210
1211    #[test]
1212    fn parse_routing_host_case_insensitive() {
1213        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1214        assert_eq!(h.service, "sqs");
1215        assert_eq!(h.region, "us-east-1");
1216
1217        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1218        assert_eq!(h.service, "lambda");
1219        assert_eq!(h.region, "us-east-1");
1220    }
1221
1222    #[test]
1223    fn parse_routing_host_localstack_s3_virtual_hosted() {
1224        let h =
1225            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1226        assert_eq!(h.service, "s3");
1227        assert_eq!(h.region, "us-east-1");
1228        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1229    }
1230
1231    #[test]
1232    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1233        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1234        assert_eq!(h.service, "s3");
1235        assert_eq!(h.region, "us-east-1");
1236        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1237    }
1238
1239    #[test]
1240    fn parse_routing_host_aws_service_region() {
1241        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1242        assert_eq!(h.service, "sqs");
1243        assert_eq!(h.region, "us-east-1");
1244        assert!(h.bucket.is_none());
1245
1246        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1247        assert_eq!(h.service, "dynamodb");
1248        assert_eq!(h.region, "eu-west-2");
1249    }
1250
1251    #[test]
1252    fn parse_routing_host_aws_s3_path_style_modern() {
1253        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1254        assert_eq!(h.service, "s3");
1255        assert_eq!(h.region, "us-east-1");
1256        assert!(h.bucket.is_none());
1257    }
1258
1259    #[test]
1260    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1261        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1262        assert_eq!(h.service, "s3");
1263        assert_eq!(h.region, "us-east-1");
1264        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1265    }
1266
1267    #[test]
1268    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1269        let h = parse_routing_host("a.b.c.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("a.b.c"));
1273    }
1274
1275    #[test]
1276    fn parse_routing_host_aws_s3_legacy_global() {
1277        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
1278        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
1279        let h = parse_routing_host("s3.amazonaws.com").unwrap();
1280        assert_eq!(h.service, "s3");
1281        assert_eq!(h.region, "us-east-1");
1282        assert!(h.bucket.is_none());
1283
1284        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1285        assert_eq!(h.service, "s3");
1286        assert_eq!(h.region, "us-east-1");
1287        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1288    }
1289
1290    #[test]
1291    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1292        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
1293        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
1294        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1295        assert_eq!(h.service, "s3");
1296        assert_eq!(h.region, "us-east-1");
1297        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1298    }
1299
1300    #[test]
1301    fn parse_routing_host_aws_s3_dash_separated() {
1302        // Older dash-separated form still served by AWS.
1303        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1304        assert_eq!(h.service, "s3");
1305        assert_eq!(h.region, "us-west-2");
1306        assert!(h.bucket.is_none());
1307
1308        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1309        assert_eq!(h.service, "s3");
1310        assert_eq!(h.region, "us-west-2");
1311        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1312    }
1313
1314    #[test]
1315    fn parse_routing_host_rejects_plain_localhost() {
1316        assert!(parse_routing_host("localhost:4566").is_none());
1317        assert!(parse_routing_host("127.0.0.1:4566").is_none());
1318    }
1319
1320    #[test]
1321    fn parse_routing_host_rejects_unknown_suffix() {
1322        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1323        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1324    }
1325
1326    #[test]
1327    fn parse_routing_host_empty_and_malformed_rejected() {
1328        assert!(parse_routing_host("").is_none());
1329        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1330        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1331        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1332        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1333        assert!(parse_routing_host(".amazonaws.com").is_none());
1334        assert!(parse_routing_host("amazonaws.com").is_none());
1335    }
1336
1337    #[test]
1338    fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1339        // A single-label "s3-accesspoint" host has < 2 labels, so the
1340        // virtual-hosted `len() - 2` slice would underflow and panic without
1341        // the length guard. It must be rejected, not crash the router.
1342        assert!(parse_routing_host("s3-accesspoint").is_none());
1343    }
1344
1345    #[test]
1346    fn detect_service_via_host_for_rest_service() {
1347        let mut headers = HeaderMap::new();
1348        headers.insert(
1349            "host",
1350            "s3.us-east-1.localhost.localstack.cloud:4566"
1351                .parse()
1352                .unwrap(),
1353        );
1354        let query = HashMap::new();
1355        let body = Bytes::new();
1356        let detected = detect_service(&headers, &query, &body).unwrap();
1357        assert_eq!(detected.service, "s3");
1358        assert_eq!(detected.protocol, AwsProtocol::Rest);
1359    }
1360
1361    #[test]
1362    fn detect_service_via_host_for_rest_json_service() {
1363        let mut headers = HeaderMap::new();
1364        headers.insert(
1365            "host",
1366            "lambda.us-east-1.localhost.localstack.cloud:4566"
1367                .parse()
1368                .unwrap(),
1369        );
1370        let query = HashMap::new();
1371        let body = Bytes::new();
1372        let detected = detect_service(&headers, &query, &body).unwrap();
1373        assert_eq!(detected.service, "lambda");
1374        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1375    }
1376
1377    #[test]
1378    fn detect_service_via_host_plus_query_action() {
1379        let mut headers = HeaderMap::new();
1380        headers.insert(
1381            "host",
1382            "sqs.us-east-1.localhost.localstack.cloud:4566"
1383                .parse()
1384                .unwrap(),
1385        );
1386        let mut query = HashMap::new();
1387        query.insert("Action".to_string(), "ListQueues".to_string());
1388        let body = Bytes::new();
1389        let detected = detect_service(&headers, &query, &body).unwrap();
1390        assert_eq!(detected.service, "sqs");
1391        assert_eq!(detected.action, "ListQueues");
1392        assert_eq!(detected.protocol, AwsProtocol::Query);
1393    }
1394
1395    #[test]
1396    fn detect_service_sigv4_wins_over_host() {
1397        let mut headers = HeaderMap::new();
1398        headers.insert(
1399            "authorization",
1400            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1401             SignedHeaders=host, Signature=abc"
1402                .parse()
1403                .unwrap(),
1404        );
1405        headers.insert(
1406            "host",
1407            "lambda.us-east-1.localhost.localstack.cloud:4566"
1408                .parse()
1409                .unwrap(),
1410        );
1411        let query = HashMap::new();
1412        let body = Bytes::new();
1413        let detected = detect_service(&headers, &query, &body).unwrap();
1414        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
1415        assert_eq!(detected.service, "s3");
1416        assert_eq!(detected.protocol, AwsProtocol::Rest);
1417    }
1418
1419    #[test]
1420    fn detect_service_host_for_virtual_hosted_s3() {
1421        let mut headers = HeaderMap::new();
1422        headers.insert(
1423            "host",
1424            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1425                .parse()
1426                .unwrap(),
1427        );
1428        let query = HashMap::new();
1429        let body = Bytes::new();
1430        let detected = detect_service(&headers, &query, &body).unwrap();
1431        assert_eq!(detected.service, "s3");
1432        assert_eq!(detected.protocol, AwsProtocol::Rest);
1433    }
1434}