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    /// EC2 Query protocol: a variant of Query. Requests are wire-identical to
12    /// `Query` (form-encoded body, `Action` param), but responses — including
13    /// errors — use EC2's distinct XML envelope. Where `Query`/awsQuery emits
14    /// `<ErrorResponse><Error><Type/><Code/><Message/></Error><RequestId/>`,
15    /// ec2Query emits `<Response><Errors><Error><Code/><Message/></Error></Errors><RequestID/></Response>`
16    /// (root `<Response>`, an `<Errors>` wrapper, no `<Type>`, and capital-ID
17    /// `<RequestID>`). The AWS SDKs (aws-sdk-go-v2/Terraform, aws-sdk-js v3,
18    /// aws-sdk-rust, aws-sdk-java v2) parse EC2 error codes strictly against
19    /// this shape. Used by: EC2.
20    Ec2Query,
21    /// JSON protocol: JSON body, X-Amz-Target header, JSON response.
22    /// Used by: SSM, EventBridge, DynamoDB, SecretsManager, KMS, CloudWatch Logs.
23    Json,
24    /// REST protocol: HTTP method + path-based routing, XML responses.
25    /// Used by: S3, API Gateway, Route53.
26    Rest,
27    /// REST-JSON protocol: HTTP method + path-based routing, JSON responses.
28    /// Used by: Lambda, SES v2.
29    RestJson,
30}
31
32/// Pick the Query-family protocol for a resolved service. EC2 speaks the
33/// `ec2Query` variant (distinct error/response envelope); every other
34/// Action-param service uses plain `awsQuery`.
35fn query_protocol_for(service: &str) -> AwsProtocol {
36    if service == "ec2" {
37        AwsProtocol::Ec2Query
38    } else {
39        AwsProtocol::Query
40    }
41}
42
43/// Services that use REST protocol with XML responses (detected from SigV4 credential scope).
44const REST_XML_SERVICES: &[&str] = &["s3", "cloudfront", "route53"];
45
46/// Services that use REST protocol with JSON responses (detected from SigV4 credential scope).
47const REST_JSON_SERVICES: &[&str] = &[
48    "managedblockchain",
49    "lambda",
50    "ses",
51    "apigateway",
52    "bedrock",
53    "bedrock-agent",
54    "bedrock-agent-runtime",
55    "scheduler",
56    "batch",
57    "pipes",
58    "rds-data",
59    "dsql",
60    "resource-groups",
61    "eks",
62    "glacier",
63    "backup",
64    // AWS Resource Access Manager: restJson1 control plane (single-segment
65    // `@http` POST/DELETE URIs + JSON bodies).
66    "ram",
67    // Amazon S3 Tables: restJson1 control plane (path-labelled `@http` URIs
68    // over the table bucket -> namespace -> table hierarchy + JSON bodies).
69    "s3tables",
70    // AWS Lake Formation: restJson1 governance control plane over Glue
71    // (single-segment `@http` `POST /<Op>` URIs + JSON bodies).
72    "lakeformation",
73    // Amazon OpenSearch Service + Amazon Elasticsearch Service both sign as
74    // `es` and speak restJson1; one service handles both, splitting on the
75    // URL path version prefix.
76    "es",
77    "account",
78    // AWS AppConfig control plane + AppConfig Data plane both sign as
79    // `appconfig` and speak restJson1; one service handles both, splitting on
80    // the URL path (control `/applications/...` vs data `/configuration...`).
81    "appconfig",
82    // AWS CodeArtifact: restJson1 control plane (`@http` method + path routing,
83    // multi-value query params, JSON bodies).
84    "codeartifact",
85    // Amazon EFS: restJson1 control plane (path-labelled `@http` URIs over file
86    // systems, mount targets, access points; JSON bodies). Signs as
87    // `elasticfilesystem`.
88    "elasticfilesystem",
89    // Amazon MQ: restJson1 control plane (path-labelled `@http` URIs over
90    // brokers, configurations, users, and tags; JSON bodies). Signs as `mq`.
91    "mq",
92    // Amazon MSK (Managed Streaming for Apache Kafka): restJson1 control plane
93    // (path-labelled `@http` URIs over clusters, configurations, operations,
94    // replicators, VPC connections, and topics; JSON bodies). Signs as `kafka`.
95    "kafka",
96    // Amazon MWAA (Managed Workflows for Apache Airflow): restJson1 control
97    // plane (path-labelled `@http` URIs over environments, access tokens, and
98    // tags; JSON bodies). Signs as `airflow`, normalized to `mwaa`.
99    "mwaa",
100    // AWS Fault Injection Simulator: restJson1 control plane (path-labelled
101    // `@http` URIs over experiment templates, experiments, the actions and
102    // target-resource-type catalogs, target-account configurations, safety
103    // levers, and tags; JSON bodies). Signs as `fis`.
104    "fis",
105    // AWS X-Ray: restJson1 control plane + trace data plane (fixed `@http`
106    // `POST /<Op>` URIs over trace segments, the derived service graph,
107    // sampling rules, groups, encryption config, and tags; JSON bodies).
108    // Signs as `xray`.
109    "xray",
110    // AWS AppSync: restJson1 control plane + schema state (RESTful `@http`
111    // method + path routing over GraphQL APIs, data sources, resolvers,
112    // functions, types, caches, domain names, the Event-API surface, and
113    // source-API associations; JSON bodies). Signs as `appsync`.
114    "appsync",
115    // AWS Amplify: restJson1 hosting control plane (path-labelled `@http` URIs
116    // over apps, branches, domain associations, webhooks, backend
117    // environments, jobs/deployments, artifacts, and tags; JSON bodies).
118    // Signs as `amplify`.
119    "amplify",
120    // AWS Elemental MediaConvert: restJson1 video-transcoding control plane
121    // (path-labelled `@http` URIs under `/2017-08-29` over queues, presets, job
122    // templates, jobs, policy, endpoints, and tags; JSON bodies). Signs as
123    // `mediaconvert`.
124    "mediaconvert",
125    // AWS Serverless Application Repository: restJson1 control plane
126    // (path-labelled `@http` URIs under `/applications` over applications,
127    // versions, sharing policies, CloudFormation change sets/templates, and
128    // dependencies; JSON bodies). Signs as `serverlessrepo`.
129    "serverlessrepo",
130    // AWS IoT Data Plane: restJson1 device-shadow + retained-message data plane
131    // (path-labelled `@http` URIs over `/things/{thingName}/shadow`,
132    // `/topics/{topic}`, `/retainedMessage`, and `/connections`; raw
133    // `@httpPayload` shadow documents). Signs as `iotdata`.
134    "iotdata",
135    // Amazon Pinpoint: restJson1 control plane (RESTful `@http` method + path
136    // routing under `/v1/apps/...`, `/v1/templates/...`, `/v1/recommenders`,
137    // `/v1/tags/...` over apps, campaigns, segments, endpoints, channels,
138    // journeys, templates, jobs, event streams, recommenders, and tags; JSON
139    // bodies). Signs as `mobiletargeting`, normalized to `pinpoint`.
140    "pinpoint",
141    // AWS IoT Core control plane: restJson1 registry / jobs / rules / security
142    // control plane (path-labelled `@http` URIs over `/things/{thingName}`,
143    // `/policies/{policyName}`, `/jobs/{jobId}`, `/rules/{ruleName}`, ...).
144    // Signs as `iot`.
145    "iot",
146    // AWS IoT Wireless control plane: restJson1 LoRaWAN / Sidewalk registry
147    // (collection-POST creates + path-labelled reads over `/destinations`,
148    // `/wireless-devices/{Identifier}`, `/fuota-tasks/{Id}`, ...). Signs as
149    // `iotwireless`.
150    "iotwireless",
151];
152
153/// Detected service name and action from an incoming HTTP request.
154#[derive(Debug, Clone)]
155pub struct DetectedRequest {
156    pub service: String,
157    pub action: String,
158    pub protocol: AwsProtocol,
159}
160
161/// Header-only service detection. Skips the form-encoded body sniff so
162/// the dispatch path can decide whether to stream or buffer the body
163/// without first reading it. Returns `None` when only a body sniff
164/// would succeed; the caller must then fall back to [`detect_service`]
165/// after buffering. Used to opt streaming routes (S3 PutObject /
166/// UploadPart, ECR OCI v2 blob upload) out of the global body cap.
167pub fn detect_service_headers_only(
168    headers: &HeaderMap,
169    query_params: &HashMap<String, String>,
170) -> Option<DetectedRequest> {
171    // Mirrors `detect_service` minus step 3 (form-body sniff).
172    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
173        return parse_amz_target(target);
174    }
175    if let Some(action) = query_params.get("Action") {
176        let service = extract_service_from_auth(headers)
177            .or_else(|| infer_service_from_action(action))
178            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
179        if let Some(service) = service {
180            let protocol = query_protocol_for(&service);
181            return Some(DetectedRequest {
182                service,
183                action: action.clone(),
184                protocol,
185            });
186        }
187    }
188    if let Some(service) = extract_service_from_auth(headers) {
189        if let Some(protocol) = rest_protocol_for(&service) {
190            return Some(DetectedRequest {
191                service,
192                action: String::new(),
193                protocol,
194            });
195        }
196    }
197    if let Some(credential) = query_params.get("X-Amz-Credential") {
198        let parts: Vec<&str> = credential.split('/').collect();
199        if parts.len() >= 4 {
200            let service = normalize_service_name(parts[3]).to_string();
201            if let Some(protocol) = rest_protocol_for(&service) {
202                return Some(DetectedRequest {
203                    service,
204                    action: String::new(),
205                    protocol,
206                });
207            }
208        }
209    }
210    if query_params.contains_key("AWSAccessKeyId")
211        && query_params.contains_key("Signature")
212        && query_params.contains_key("Expires")
213    {
214        return Some(DetectedRequest {
215            service: "s3".to_string(),
216            action: String::new(),
217            protocol: AwsProtocol::Rest,
218        });
219    }
220    if let Some(host_info) = parse_routing_host_from_headers(headers) {
221        if let Some(protocol) = rest_protocol_for(&host_info.service) {
222            return Some(DetectedRequest {
223                service: host_info.service,
224                action: String::new(),
225                protocol,
226            });
227        }
228    }
229    None
230}
231
232/// Detect the target service and action from HTTP request components.
233pub fn detect_service(
234    headers: &HeaderMap,
235    query_params: &HashMap<String, String>,
236    body: &Bytes,
237) -> Option<DetectedRequest> {
238    // 1. Check X-Amz-Target header (JSON protocol)
239    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
240        return parse_amz_target(target);
241    }
242
243    // 2. Check for Query protocol (Action parameter in query string or form body)
244    if let Some(action) = query_params.get("Action") {
245        let service = extract_service_from_auth(headers)
246            .or_else(|| infer_service_from_action(action))
247            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
248        if let Some(service) = service {
249            let protocol = query_protocol_for(&service);
250            return Some(DetectedRequest {
251                service,
252                action: action.clone(),
253                protocol,
254            });
255        }
256    }
257
258    // 3. Try form-encoded body
259    {
260        let form_params = decode_form_urlencoded(body);
261
262        if let Some(action) = form_params.get("Action") {
263            let service = extract_service_from_auth(headers)
264                .or_else(|| infer_service_from_action(action))
265                .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
266            if let Some(service) = service {
267                let protocol = query_protocol_for(&service);
268                return Some(DetectedRequest {
269                    service,
270                    action: action.clone(),
271                    protocol,
272                });
273            }
274        }
275    }
276
277    // 4. Fallback: check auth header for REST-style services (S3, Lambda, SES, etc.)
278    if let Some(service) = extract_service_from_auth(headers) {
279        if let Some(protocol) = rest_protocol_for(&service) {
280            return Some(DetectedRequest {
281                service,
282                action: String::new(), // REST services determine action from method+path
283                protocol,
284            });
285        }
286    }
287
288    // 5. Check query params for presigned URL auth (X-Amz-Credential for SigV4)
289    if let Some(credential) = query_params.get("X-Amz-Credential") {
290        // Format: AKID/date/region/service/aws4_request
291        let parts: Vec<&str> = credential.split('/').collect();
292        if parts.len() >= 4 {
293            let service = normalize_service_name(parts[3]).to_string();
294            if let Some(protocol) = rest_protocol_for(&service) {
295                return Some(DetectedRequest {
296                    service,
297                    action: String::new(),
298                    protocol,
299                });
300            }
301        }
302    }
303
304    // 6. Check for SigV2-style presigned URL (AWSAccessKeyId + Signature + Expires)
305    //    Only match when all three SigV2 presigned-URL parameters are present so
306    //    we don't accidentally claim non-S3 requests.
307    if query_params.contains_key("AWSAccessKeyId")
308        && query_params.contains_key("Signature")
309        && query_params.contains_key("Expires")
310    {
311        return Some(DetectedRequest {
312            service: "s3".to_string(),
313            action: String::new(),
314            protocol: AwsProtocol::Rest,
315        });
316    }
317
318    // 7. Fallback: unsigned REST-style request carrying a LocalStack-shaped
319    //    Host header. Lets fixtures and curl-style probes reach the right
320    //    service without SigV4; signed requests were already handled in step 4.
321    if let Some(host_info) = parse_routing_host_from_headers(headers) {
322        if let Some(protocol) = rest_protocol_for(&host_info.service) {
323            return Some(DetectedRequest {
324                service: host_info.service,
325                action: String::new(),
326                protocol,
327            });
328        }
329    }
330
331    None
332}
333
334/// Service + region (and optional bucket) decoded from a `Host` header.
335/// Covers both the LocalStack hostname convention
336/// (`<service>.<region>.localhost.localstack.cloud[:port]`,
337/// `<bucket>.s3.<region>.localhost.localstack.cloud[:port]`) and real AWS
338/// service hostnames (`<service>.<region>.amazonaws.com`, S3 path-style
339/// and virtual-hosted-style including the legacy no-region
340/// `s3.amazonaws.com` / `<bucket>.s3.amazonaws.com` forms, the older
341/// dash-separated `s3-<region>.amazonaws.com` form, the dualstack, FIPS and
342/// static-website endpoints), under every AWS partition's DNS suffix.
343#[derive(Debug, Clone, PartialEq, Eq)]
344pub struct RoutingHost {
345    pub service: String,
346    pub region: String,
347    /// Set only for virtual-hosted-style S3 hostnames.
348    pub bucket: Option<String>,
349}
350
351const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
352
353/// Parse a `Host` header value for a LocalStack- or AWS-shaped hostname.
354/// Returns `None` for anything that doesn't match — callers fall through
355/// to their existing detection path.
356pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
357    let hostname = host.split(':').next()?;
358    if hostname.is_empty() {
359        return None;
360    }
361    let hostname = hostname.to_ascii_lowercase();
362    if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
363        return parse_localstack_prefix(prefix);
364    }
365    // Endpoint hostnames share one shape across partitions; only the DNS
366    // suffix differs, so accept every partition's.
367    fakecloud_aws::endpoint::DNS_SUFFIXES
368        .iter()
369        .find_map(|suffix| hostname.strip_suffix(suffix)?.strip_suffix('.'))
370        .and_then(parse_aws_prefix)
371}
372
373/// Pull the `Host` header and parse it with [`parse_routing_host`].
374pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
375    let host = headers.get("host")?.to_str().ok()?;
376    parse_routing_host(host)
377}
378
379fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
380    if prefix.is_empty() {
381        return None;
382    }
383    let labels: Vec<&str> = prefix.split('.').collect();
384    if labels.iter().any(|l| l.is_empty()) {
385        return None;
386    }
387    match labels.len() {
388        2 => Some(RoutingHost {
389            service: labels[0].to_string(),
390            region: labels[1].to_string(),
391            bucket: None,
392        }),
393        n if n >= 3 && labels[n - 2] == "s3" => {
394            let bucket = labels[..n - 2].join(".");
395            Some(RoutingHost {
396                service: "s3".to_string(),
397                region: labels[n - 1].to_string(),
398                bucket: Some(bucket),
399            })
400        }
401        n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
402            let bucket = labels[..n - 2].join(".");
403            Some(RoutingHost {
404                service: "s3".to_string(),
405                region: labels[n - 1].to_string(),
406                bucket: Some(bucket),
407            })
408        }
409        n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
410            service: "s3".to_string(),
411            region: labels[n - 1].to_string(),
412            bucket: None,
413        }),
414        _ => None,
415    }
416}
417
418/// Parse the prefix before an AWS partition DNS suffix (`.amazonaws.com`, ...).
419///
420/// Handles every variant AWS has shipped for the common REST/Query services:
421///
422/// - `<service>.<region>` — modern regional endpoint (most services).
423/// - `s3.<region>` — modern path-style S3.
424/// - `<bucket>.s3.<region>` — modern virtual-hosted S3 (bucket may contain dots).
425/// - `s3` — legacy S3 global endpoint (implicitly `us-east-1`).
426/// - `<bucket>.s3` — legacy virtual-hosted S3 (implicitly `us-east-1`).
427/// - `s3-<region>` — older dash-separated path-style S3.
428/// - `<bucket>.s3-<region>` — older dash-separated virtual-hosted S3.
429/// - `[<bucket>.]s3.dualstack.<region>`, `[<bucket>.]s3-fips[.dualstack].<region>`
430///   — dualstack and FIPS S3 endpoints.
431/// - `<bucket>.s3-website-<region>` / `<bucket>.s3-website.<region>` — S3
432///   static-website endpoints.
433fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
434    if prefix.is_empty() {
435        return None;
436    }
437    let labels: Vec<&str> = prefix.split('.').collect();
438    if labels.iter().any(|l| l.is_empty()) {
439        return None;
440    }
441    let last = *labels.last()?;
442
443    // `<bucket>.s3-website-<region>`: dash-separated S3 static-website
444    // endpoint. Checked before the generic `s3-<region>` form below, which
445    // would otherwise read the region as `website-<region>`.
446    if let Some(region) = last.strip_prefix("s3-website-") {
447        if !region.is_empty() && labels.len() >= 2 {
448            return Some(RoutingHost {
449                service: "s3".to_string(),
450                region: region.to_string(),
451                bucket: Some(labels[..labels.len() - 1].join(".")),
452            });
453        }
454    }
455
456    // `s3-<region>` as the last label: dash-separated S3. Bucket, if any,
457    // is whatever precedes it.
458    if let Some(region) = last.strip_prefix("s3-") {
459        if !region.is_empty() {
460            let bucket = if labels.len() >= 2 {
461                Some(labels[..labels.len() - 1].join("."))
462            } else {
463                None
464            };
465            return Some(RoutingHost {
466                service: "s3".to_string(),
467                region: region.to_string(),
468                bucket,
469            });
470        }
471    }
472
473    // Legacy global S3: last label is `s3`, no region present. `s3` on its
474    // own is the path-style global endpoint; anything preceding it is the
475    // bucket (including dotted names like `a.b.s3.amazonaws.com`).
476    if last == "s3" {
477        if labels.len() == 1 {
478            return Some(RoutingHost {
479                service: "s3".to_string(),
480                region: "us-east-1".to_string(),
481                bucket: None,
482            });
483        }
484        return Some(RoutingHost {
485            service: "s3".to_string(),
486            region: "us-east-1".to_string(),
487            bucket: Some(labels[..labels.len() - 1].join(".")),
488        });
489    }
490
491    // `s3-accesspoint.<region>` — path-style access point endpoint.
492    // `{alias}-{account-id}.s3-accesspoint.<region>` — virtual-hosted access point.
493    if last == "s3-accesspoint" {
494        if labels.len() == 2 {
495            return Some(RoutingHost {
496                service: "s3".to_string(),
497                region: labels[0].to_string(),
498                bucket: None,
499            });
500        }
501        // Virtual-hosted form needs at least {alias}.{region}.s3-accesspoint, i.e.
502        // 3+ labels. A bare "s3-accesspoint" host (1 label) must not reach the
503        // `len() - 2` slice, which would underflow and panic.
504        if labels.len() >= 3 {
505            let bucket = labels[..labels.len() - 2].join(".");
506            return Some(RoutingHost {
507                service: "s3".to_string(),
508                region: labels[labels.len() - 1].to_string(),
509                bucket: Some(bucket),
510            });
511        }
512    }
513
514    // `s3-control.<region>` or `{account-id}.s3-control.<region>` — S3
515    // Control endpoint (access point management).
516    if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
517        return Some(RoutingHost {
518            service: "s3".to_string(),
519            region: last.to_string(),
520            bucket: None,
521        });
522    }
523
524    // Region-last S3 endpoints: `[<bucket>.]<endpoint>.<region>` where the
525    // endpoint is `s3`, `s3-fips`, either of those followed by `.dualstack`,
526    // or `s3-website` (the dot-separated static-website endpoint). Whatever
527    // precedes the endpoint is the bucket (dotted names included).
528    let before_region = &labels[..labels.len() - 1];
529    let (endpoint_labels, s3_endpoint) = match before_region {
530        [rest @ .., s3, "dualstack"] if matches!(*s3, "s3" | "s3-fips") => (rest, true),
531        [rest @ .., endpoint] if matches!(*endpoint, "s3" | "s3-fips" | "s3-website") => {
532            (rest, true)
533        }
534        _ => (before_region, false),
535    };
536    if s3_endpoint {
537        return Some(RoutingHost {
538            service: "s3".to_string(),
539            region: last.to_string(),
540            bucket: (!endpoint_labels.is_empty()).then(|| endpoint_labels.join(".")),
541        });
542    }
543
544    // `<service>.<region>` — the common case for every other service.
545    match labels.as_slice() {
546        [service, region] => Some(RoutingHost {
547            service: service.to_string(),
548            region: region.to_string(),
549            bucket: None,
550        }),
551        _ => None,
552    }
553}
554
555/// Parse `X-Amz-Target: AWSEvents.PutEvents` -> service=events, action=PutEvents
556/// Parse `X-Amz-Target: AmazonSSM.GetParameter` -> service=ssm, action=GetParameter
557fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
558    let (prefix, action) = target.rsplit_once('.')?;
559
560    let service = match prefix {
561        "AWSEvents" => "events",
562        "AmazonSSM" => "ssm",
563        "AmazonSQS" => "sqs",
564        "AmazonSNS" => "sns",
565        "DynamoDB_20120810" => "dynamodb",
566        "DynamoDBStreams_20120810" => "dynamodbstreams",
567        "Logs_20140328" => "logs",
568        s if s.starts_with("secretsmanager") => "secretsmanager",
569        s if s.starts_with("TrentService") => "kms",
570        s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
571        s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
572        s if s.starts_with("Kinesis_20131202") => "kinesis",
573        s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
574        s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
575        s if s.starts_with("AWSStepFunctions") => "states",
576        s if s.starts_with("AWSOrganizationsV") => "organizations",
577        "CertificateManager" => "acm",
578        "ACMPrivateCA" => "acm-pca",
579        // Amazon Route 53 Resolver (resolver endpoints/rules, query logging, DNS
580        // Firewall): awsJson1_1. The service shape short name is the target
581        // prefix. Distinct from Route 53 (`route53`, a REST-XML service).
582        "Route53Resolver" => "route53resolver",
583        // AWS Config (config recorder / rules / compliance): awsJson1_1. The
584        // service shape short name is the target prefix.
585        "StarlingDoveService" => "config",
586        "AnyScaleFrontendService" => "application-autoscaling",
587        // Match the WAFv2 target version exactly so legacy WAF Classic
588        // (`AWSWAF_*` without the `_20190729` suffix) doesn't get routed here.
589        "AWSWAF_20190729" => "wafv2",
590        "AmazonAthena" => "athena",
591        s if s.starts_with("Firehose_") => "firehose",
592        "AWSGlue" => "glue",
593        // Amazon EMR (Elastic MapReduce): awsJson1.1. The service shape short
594        // name is the target prefix (`ElasticMapReduce.<Operation>`).
595        "ElasticMapReduce" => "emr",
596        // Amazon Textract (document text/analysis extraction): awsJson1_1. The
597        // service shape short name is the target prefix (`Textract.<Operation>`).
598        "Textract" => "textract",
599        // Amazon Transcribe: awsJson1.1. The service shape short name is the
600        // target prefix (`Transcribe.<Operation>`).
601        "Transcribe" => "transcribe",
602        // Amazon Translate: awsJson1_1. The service shape short name
603        // (`AWSShineFrontendService_20170701.<Operation>`) is the target prefix.
604        "AWSShineFrontendService_20170701" => "translate",
605        // AWS Shield / Shield Advanced: awsJson1_1. The service shape short
606        // name (`AWSShield_20160616.<Operation>`) is the target prefix.
607        "AWSShield_20160616" => "shield",
608        // Amazon Comprehend (NLP): awsJson1.1. The service shape name carries the
609        // dated version (`Comprehend_20171127.<Operation>`).
610        "Comprehend_20171127" => "comprehend",
611        // Amazon SWF (Simple Workflow Service): awsJson1_0. The service shape
612        // short name is the target prefix (`SimpleWorkflowService.<Operation>`).
613        "SimpleWorkflowService" => "swf",
614        // Amazon Timestream (Write + Query): awsJson1_0. BOTH the write and
615        // query SDK clients carry the SAME dated target prefix
616        // (`Timestream_20181101.<Operation>`); one fakecloud crate serves both.
617        "Timestream_20181101" => "timestream",
618        // AWS Support: awsJson1.1. The service shape carries the dated version
619        // (`AWSSupport_20130415.<Operation>`).
620        "AWSSupport_20130415" => "support",
621        "CloudApiService" => "cloudcontrolapi",
622        "ResourceGroupsTaggingAPI_20170126" => "tagging",
623        "AmazonMemoryDB" => "memorydb",
624        // Amazon Managed Service for Apache Flink (formerly Kinesis Data
625        // Analytics v2): awsJson1.1. The SigV4 signing name is
626        // `kinesisanalytics`; the internal fakecloud service key is
627        // `kinesisanalyticsv2`.
628        s if s.starts_with("KinesisAnalytics_20180523") => "kinesisanalyticsv2",
629        // Cloud Map (servicediscovery): awsJson1.1, target prefix carries the
630        // dated Route53 Auto Naming service version.
631        "Route53AutoNaming_v20170314" => "servicediscovery",
632        // Database Migration Service: awsJson1.1.
633        "AmazonDMSv20160101" => "dms",
634        // CloudTrail: awsJson1.1.
635        // aws-sdk-go-v2 / smithy clients (and terraform) send the short shape
636        // name; aws-sdk-go-v1 sends the fully-qualified form. Accept both.
637        "CloudTrail_20131101" => "cloudtrail",
638        "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
639        // Cost Explorer: awsJson1.1. aws-sdk / smithy clients (and terraform)
640        // send the short service-shape name; older clients may send the
641        // fully-qualified form. Accept both.
642        "AWSInsightsIndexService" => "ce",
643        "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
644        // Transfer Family: awsJson1.1.
645        "TransferService" => "transfer",
646        // AWS CodeBuild: awsJson1.1, target prefix is the dated service shape.
647        "CodeBuild_20161006" => "codebuild",
648        // AWS CodeCommit: awsJson1.1, target prefix is the dated service shape.
649        "CodeCommit_20150413" => "codecommit",
650        // IAM Identity Center Identity Store: awsJson1.1.
651        "AWSIdentityStore" => "identitystore",
652        // IAM Identity Center SSO Admin: awsJson1.1.
653        "SWBExternalService" => "sso",
654        // Verified Permissions: awsJson1.0.
655        "VerifiedPermissions" => "verifiedpermissions",
656        // CodeConnections (successor to CodeStar Connections): awsJson1.0.
657        "CodeConnections_20231201" => "codeconnections",
658        // Legacy CodeStar Connections API (same operations as CodeConnections);
659        // the terraform `aws_codestarconnections_connection` resource still
660        // signs with this dated prefix (note the lowercase `connections`, as
661        // emitted by the aws-sdk-go-v2 codestarconnections client), so route it
662        // to the same handler.
663        "CodeStar_connections_20191201" => "codeconnections",
664        // AWS CodeDeploy: awsJson1.1, target prefix is the dated service shape.
665        "CodeDeploy_20141006" => "codedeploy",
666        // AWS CodePipeline: awsJson1.1, target prefix is the dated service shape.
667        "CodePipeline_20150709" => "codepipeline",
668        // CloudWatch advertises awsJson1_0 (target service shape
669        // `GraniteServiceVersion20100801`) alongside the legacy awsQuery
670        // protocol. Newer SDKs (aws-sdk-rust / js-v3 / go-v2) POST with
671        // `X-Amz-Target: GraniteServiceVersion20100801.<Operation>` and a JSON
672        // body. The service registry key is `monitoring`.
673        s if s.starts_with("GraniteServiceVersion") => "monitoring",
674        // Amazon SageMaker: awsJson1.1. The service shape short name is the
675        // target prefix (`SageMaker.<Operation>`).
676        "SageMaker" => "sagemaker",
677        _ => return None,
678    };
679
680    Some(DetectedRequest {
681        service: service.to_string(),
682        action: action.to_string(),
683        protocol: AwsProtocol::Json,
684    })
685}
686
687/// Returns the REST protocol variant for a service, or None if not a REST service.
688fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
689    if REST_XML_SERVICES.contains(&service) {
690        Some(AwsProtocol::Rest)
691    } else if REST_JSON_SERVICES.contains(&service) {
692        Some(AwsProtocol::RestJson)
693    } else {
694        None
695    }
696}
697
698/// Infer service from the action name when no SigV4 auth is present.
699/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
700/// do not require authentication and won't have an Authorization header.
701fn infer_service_from_action(action: &str) -> Option<String> {
702    match action {
703        "AssumeRole"
704        | "AssumeRoleWithSAML"
705        | "AssumeRoleWithWebIdentity"
706        | "GetCallerIdentity"
707        | "GetSessionToken"
708        | "GetFederationToken"
709        | "GetAccessKeyInfo"
710        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
711        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
712        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
713        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
714        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
715        // SES v1 (Query protocol)
716        "VerifyEmailIdentity"
717        | "VerifyDomainIdentity"
718        | "VerifyDomainDkim"
719        | "ListIdentities"
720        | "GetIdentityVerificationAttributes"
721        | "GetIdentityDkimAttributes"
722        | "DeleteIdentity"
723        | "SetIdentityDkimEnabled"
724        | "SetIdentityNotificationTopic"
725        | "SetIdentityFeedbackForwardingEnabled"
726        | "GetIdentityNotificationAttributes"
727        | "GetIdentityMailFromDomainAttributes"
728        | "SetIdentityMailFromDomain"
729        | "SendEmail"
730        | "SendRawEmail"
731        | "SendTemplatedEmail"
732        | "SendBulkTemplatedEmail"
733        | "CreateTemplate"
734        | "GetTemplate"
735        | "ListTemplates"
736        | "DeleteTemplate"
737        | "UpdateTemplate"
738        | "CreateConfigurationSet"
739        | "DeleteConfigurationSet"
740        | "DescribeConfigurationSet"
741        | "ListConfigurationSets"
742        | "CreateConfigurationSetEventDestination"
743        | "UpdateConfigurationSetEventDestination"
744        | "DeleteConfigurationSetEventDestination"
745        | "GetSendQuota"
746        | "GetSendStatistics"
747        | "GetAccountSendingEnabled"
748        | "CreateReceiptRuleSet"
749        | "DeleteReceiptRuleSet"
750        | "DescribeReceiptRuleSet"
751        | "ListReceiptRuleSets"
752        | "CloneReceiptRuleSet"
753        | "SetActiveReceiptRuleSet"
754        | "ReorderReceiptRuleSet"
755        | "CreateReceiptRule"
756        | "DeleteReceiptRule"
757        | "DescribeReceiptRule"
758        | "UpdateReceiptRule"
759        | "CreateReceiptFilter"
760        | "DeleteReceiptFilter"
761        | "ListReceiptFilters" => Some("ses".to_string()),
762        // SNS subscription handshake: the SubscribeURL / UnsubscribeUrl that SNS
763        // hands to HTTP/S and email subscribers are unsigned bare GETs (no auth
764        // header), so the service must be inferred from the action alone.
765        "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
766        _ => None,
767    }
768}
769
770/// Extract service name from the SigV4 Authorization header credential scope.
771fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
772    let auth = headers.get("authorization")?.to_str().ok()?;
773    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
774    Some(normalize_service_name(&info.service).to_string())
775}
776
777/// Map AWS service-name aliases that share path namespace and handlers
778/// to the canonical form used by fakecloud's service registry.
779///
780/// AWS uses `bedrock-runtime` in the SigV4 credential scope of runtime
781/// API calls (`InvokeModel`, `ApplyGuardrail`, etc.) but the REST paths
782/// (e.g. `POST /guardrail/{id}/version/{ver}/apply`) live under the same
783/// `BedrockService` handler that owns the control-plane `bedrock` paths.
784/// Without normalization, `detect_service` returns `None` for
785/// `bedrock-runtime` (not in `REST_JSON_SERVICES`), the central
786/// dispatcher falls back to API Gateway, and `/guardrail/...` 404s with
787/// `NotFoundException: Stage not found: guardrail`. See issue #1232.
788fn normalize_service_name(service: &str) -> &str {
789    match service {
790        "bedrock-runtime" => "bedrock",
791        // Real AWS API Gateway V2 SDK signs with `apigateway` as the SigV4
792        // service (per the model's `aws.api#service.endpointPrefix`), but
793        // tools driven by the Smithy service shape name (including our own
794        // conformance probe) may send `apigatewayv2`. Both refer to the
795        // same fakecloud service registry entry — the v2 handler is path-
796        // routed under `/v2/...` and the v1 handler under `/restapis/...`,
797        // both reachable behind the `apigateway` SigV4 service.
798        "apigatewayv2" => "apigateway",
799        // Amazon OpenSearch Service has no dedicated SigV4 signing scope: its
800        // SDK signs with `es` (the shared Elasticsearch Service scope), so a
801        // real OpenSearch request already arrives as `es` and needs no
802        // normalization. The conformance probe, however, signs with the
803        // Smithy service shape name `opensearch`; alias it to `es` so both the
804        // 2015 (Elasticsearch) and 2021 (OpenSearch) probes resolve to the one
805        // registry entry, which then routes on the URL path version prefix.
806        "opensearch" => "es",
807        // AWS AppConfig Data signs with the shared `appconfig` scope, so a real
808        // request already arrives as `appconfig`. The conformance probe signs
809        // the data-plane operations with the Smithy service shape name
810        // `appconfigdata`; alias it to `appconfig` so both model-services
811        // resolve to the one registry entry, which routes on the URL path.
812        "appconfigdata" => "appconfig",
813        // Amazon MWAA signs SigV4 with the `airflow` scope (its ARN namespace),
814        // so a real SDK request arrives as `airflow`. Alias it to the `mwaa`
815        // registry entry (the conformance probe signs with the Smithy service
816        // shape name `mwaa`, which already resolves).
817        "airflow" => "mwaa",
818        // Amazon Pinpoint signs SigV4 with the `mobiletargeting` scope (its ARN
819        // namespace), so a real SDK request arrives as `mobiletargeting`. Alias
820        // it to the `pinpoint` registry entry (the conformance probe signs with
821        // the service-map `service_name`, `pinpoint`, which already resolves).
822        "mobiletargeting" => "pinpoint",
823        other => other,
824    }
825}
826
827/// Parse form-encoded body into key-value pairs.
828pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
829    decode_form_urlencoded(body)
830}
831
832/// Flatten an awsJson request body into the flat `awsQuery` key form that
833/// query-protocol handlers consume.
834///
835/// CloudWatch is served by handlers written against the awsQuery flat-key map
836/// (`MetricData.member.1.MetricName`, `StatisticValues.Sum`,
837/// `Dimensions.member.2.Value`, ...). Its Smithy model also advertises
838/// `awsJson1_0`, so modern SDKs send a nested JSON body instead. Rather than
839/// duplicate every parser, we flatten the JSON into the same map the awsQuery
840/// handlers already read:
841///
842/// - object field `K` -> key `K` (or `<parent>.K` when nested in a struct)
843/// - array element `i` (1-based) -> `<K>.member.<i>` (matching the awsQuery
844///   list wire convention)
845/// - scalars -> their string form (numbers/booleans stringified)
846///
847/// A body that is not a JSON object yields an empty map.
848pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
849    let mut out = HashMap::new();
850    let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
851        return out;
852    };
853    if value.is_object() {
854        flatten_json_value("", &value, &mut out);
855    }
856    out
857}
858
859fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
860    match value {
861        serde_json::Value::Object(map) => {
862            for (k, v) in map {
863                let child = if prefix.is_empty() {
864                    k.clone()
865                } else {
866                    format!("{prefix}.{k}")
867                };
868                flatten_json_value(&child, v, out);
869            }
870        }
871        serde_json::Value::Array(items) => {
872            for (i, v) in items.iter().enumerate() {
873                let child = format!("{prefix}.member.{}", i + 1);
874                flatten_json_value(&child, v, out);
875            }
876        }
877        serde_json::Value::Null => {}
878        serde_json::Value::String(s) => {
879            out.insert(prefix.to_string(), s.clone());
880        }
881        serde_json::Value::Bool(b) => {
882            out.insert(prefix.to_string(), b.to_string());
883        }
884        serde_json::Value::Number(n) => {
885            out.insert(prefix.to_string(), n.to_string());
886        }
887    }
888}
889
890/// Decode a query / form-urlencoded string into ordered `(key, value)` pairs,
891/// **preserving repeated keys**. The [`HashMap`] form
892/// ([`decode_form_urlencoded`]) collapses `a=1&a=2` to a single entry, which
893/// loses multi-value `@httpQuery` params; this variant keeps every occurrence
894/// in wire order for callers that need them (e.g. list-style query params).
895pub(crate) fn form_urlencoded_pairs(input: &str) -> Vec<(String, String)> {
896    let mut pairs = Vec::new();
897    for pair in input.split('&') {
898        if pair.is_empty() {
899            continue;
900        }
901        let (key, value) = match pair.find('=') {
902            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
903            None => (pair, ""),
904        };
905        pairs.push((url_decode(key), url_decode(value)));
906    }
907    pairs
908}
909
910fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
911    let s = std::str::from_utf8(input).unwrap_or("");
912    let mut result = HashMap::new();
913    for pair in s.split('&') {
914        if pair.is_empty() {
915            continue;
916        }
917        let (key, value) = match pair.find('=') {
918            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
919            None => (pair, ""),
920        };
921        result.insert(url_decode(key), url_decode(value));
922    }
923    result
924}
925
926fn url_decode(input: &str) -> String {
927    // Accumulate the decoded RAW BYTES first, then interpret the whole buffer
928    // as UTF-8. Decoding each `%XX` byte straight into a `char` would treat it
929    // as a Unicode codepoint (Latin-1), which corrupts multi-byte UTF-8
930    // sequences (e.g. "caf%C3%A9" -> "café" instead of "café"). Reassembling
931    // the bytes lets multi-byte sequences round-trip correctly.
932    let mut buf: Vec<u8> = Vec::with_capacity(input.len());
933    let mut bytes = input.bytes();
934    while let Some(b) = bytes.next() {
935        match b {
936            b'+' => buf.push(b' '),
937            b'%' => {
938                let high = bytes.next().and_then(from_hex);
939                let low = bytes.next().and_then(from_hex);
940                // A well-formed `%XX` escape decodes to a single raw byte.
941                // A malformed escape is dropped (best-effort, panic-free),
942                // matching the prior behaviour.
943                if let (Some(h), Some(l)) = (high, low) {
944                    buf.push((h << 4) | l);
945                }
946            }
947            _ => buf.push(b),
948        }
949    }
950    String::from_utf8_lossy(&buf).into_owned()
951}
952
953fn from_hex(b: u8) -> Option<u8> {
954    match b {
955        b'0'..=b'9' => Some(b - b'0'),
956        b'a'..=b'f' => Some(b - b'a' + 10),
957        b'A'..=b'F' => Some(b - b'A' + 10),
958        _ => None,
959    }
960}
961
962#[cfg(test)]
963mod tests {
964    use super::*;
965
966    #[test]
967    fn form_urlencoded_pairs_preserves_repeated_keys() {
968        // The HashMap decoder collapses repeats; the ordered-pairs variant must
969        // keep every occurrence in wire order (1.45 multi-value @httpQuery).
970        let pairs = form_urlencoded_pairs("Id=a&Id=b&Id=c&Other=x");
971        let ids: Vec<&str> = pairs
972            .iter()
973            .filter(|(k, _)| k == "Id")
974            .map(|(_, v)| v.as_str())
975            .collect();
976        assert_eq!(ids, vec!["a", "b", "c"]);
977        // Sanity: the HashMap form keeps only the last value.
978        assert_eq!(
979            decode_form_urlencoded(b"Id=a&Id=b&Id=c").get("Id").unwrap(),
980            "c"
981        );
982    }
983
984    #[test]
985    fn form_urlencoded_pairs_decodes_percent_and_plus() {
986        let pairs = form_urlencoded_pairs("q=caf%C3%A9+bar&empty=");
987        assert_eq!(pairs[0], ("q".to_string(), "café bar".to_string()));
988        assert_eq!(pairs[1], ("empty".to_string(), String::new()));
989    }
990
991    #[test]
992    fn parse_amz_target_events() {
993        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
994        assert_eq!(result.service, "events");
995        assert_eq!(result.action, "PutEvents");
996        assert_eq!(result.protocol, AwsProtocol::Json);
997    }
998
999    #[test]
1000    fn parse_amz_target_ssm() {
1001        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
1002        assert_eq!(result.service, "ssm");
1003        assert_eq!(result.action, "GetParameter");
1004    }
1005
1006    #[test]
1007    fn parse_amz_target_kinesis() {
1008        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
1009        assert_eq!(result.service, "kinesis");
1010        assert_eq!(result.action, "ListStreams");
1011        assert_eq!(result.protocol, AwsProtocol::Json);
1012    }
1013
1014    #[test]
1015    fn parse_query_body_basic() {
1016        let body = Bytes::from(
1017            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
1018        );
1019        let params = parse_query_body(&body);
1020        assert_eq!(params.get("Action").unwrap(), "SendMessage");
1021        assert_eq!(params.get("MessageBody").unwrap(), "hello");
1022    }
1023
1024    #[test]
1025    fn parse_query_body_empty_returns_empty_map() {
1026        let body = Bytes::from("");
1027        let params = parse_query_body(&body);
1028        assert!(params.is_empty());
1029    }
1030
1031    #[test]
1032    fn parse_query_body_duplicate_keys_last_wins() {
1033        let body = Bytes::from("key=a&key=b");
1034        let params = parse_query_body(&body);
1035        assert_eq!(params.get("key").unwrap(), "b");
1036    }
1037
1038    #[test]
1039    fn parse_query_body_single_key() {
1040        let body = Bytes::from("key=value");
1041        let params = parse_query_body(&body);
1042        assert_eq!(params.get("key").unwrap(), "value");
1043    }
1044
1045    #[test]
1046    fn url_decode_plain_ascii() {
1047        assert_eq!(url_decode("hello"), "hello");
1048        assert_eq!(url_decode("Action=SendMessage"), "Action=SendMessage");
1049    }
1050
1051    #[test]
1052    fn url_decode_plus_is_space() {
1053        assert_eq!(url_decode("hello+world"), "hello world");
1054        assert_eq!(url_decode("a+b+c"), "a b c");
1055    }
1056
1057    #[test]
1058    fn url_decode_multibyte_utf8_accents() {
1059        // "café" -> the é is UTF-8 0xC3 0xA9, two %-escapes for one codepoint.
1060        assert_eq!(url_decode("caf%C3%A9"), "café");
1061    }
1062
1063    #[test]
1064    fn url_decode_multibyte_utf8_cjk() {
1065        // "日本" (each char is 3 UTF-8 bytes).
1066        assert_eq!(url_decode("%E6%97%A5%E6%9C%AC"), "日本");
1067    }
1068
1069    #[test]
1070    fn url_decode_multibyte_utf8_emoji() {
1071        // "🚀" is a 4-byte UTF-8 sequence (F0 9F 9A 80).
1072        assert_eq!(url_decode("%F0%9F%9A%80"), "🚀");
1073    }
1074
1075    #[test]
1076    fn url_decode_mixed_ascii_and_multibyte() {
1077        assert_eq!(url_decode("Tag+%3D+caf%C3%A9%21"), "Tag = café!");
1078    }
1079
1080    #[test]
1081    fn url_decode_malformed_percent_is_graceful() {
1082        // A malformed escape is dropped best-effort and must never panic.
1083        assert_eq!(url_decode("100%"), "100");
1084        assert_eq!(url_decode("a%zz"), "a");
1085        assert_eq!(url_decode("a%4"), "a");
1086        // Preceding and following ASCII are preserved either side of the drop.
1087        assert_eq!(url_decode("x%y"), "x");
1088    }
1089
1090    #[test]
1091    fn url_decode_invalid_utf8_bytes_are_lossy_no_panic() {
1092        // 0xFF is not valid UTF-8; must not panic, replaced lossily.
1093        let out = url_decode("bad%FFbyte");
1094        assert!(out.starts_with("bad"));
1095        assert!(out.ends_with("byte"));
1096    }
1097
1098    #[test]
1099    fn parse_query_body_multibyte_value_round_trips() {
1100        let body = Bytes::from("Tag.Value=caf%C3%A9&Name=%E6%97%A5%E6%9C%AC");
1101        let params = parse_query_body(&body);
1102        assert_eq!(params.get("Tag.Value").unwrap(), "café");
1103        assert_eq!(params.get("Name").unwrap(), "日本");
1104    }
1105
1106    #[test]
1107    fn parse_amz_target_ecs() {
1108        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
1109        assert_eq!(result.service, "ecs");
1110        assert_eq!(result.action, "ListClusters");
1111        assert_eq!(result.protocol, AwsProtocol::Json);
1112    }
1113
1114    #[test]
1115    fn parse_amz_target_invalid_returns_none() {
1116        assert!(parse_amz_target("NoDotHere").is_none());
1117        assert!(parse_amz_target("").is_none());
1118    }
1119
1120    #[test]
1121    fn parse_amz_target_cloudwatch_json() {
1122        // CloudWatch's awsJson1_0 target service shape.
1123        let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
1124        assert_eq!(result.service, "monitoring");
1125        assert_eq!(result.action, "PutMetricData");
1126        assert_eq!(result.protocol, AwsProtocol::Json);
1127    }
1128
1129    #[test]
1130    fn flatten_json_to_query_nested() {
1131        let body = Bytes::from(
1132            serde_json::json!({
1133                "Namespace": "MyApp",
1134                "MetricData": [{
1135                    "MetricName": "Latency",
1136                    "Value": 12.5,
1137                    "StatisticValues": {"SampleCount": 3, "Sum": 10},
1138                    "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
1139                }]
1140            })
1141            .to_string(),
1142        );
1143        let flat = flatten_json_to_query(&body);
1144        assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
1145        assert_eq!(
1146            flat.get("MetricData.member.1.MetricName").unwrap(),
1147            "Latency"
1148        );
1149        assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
1150        assert_eq!(
1151            flat.get("MetricData.member.1.StatisticValues.SampleCount")
1152                .unwrap(),
1153            "3"
1154        );
1155        assert_eq!(
1156            flat.get("MetricData.member.1.Dimensions.member.1.Name")
1157                .unwrap(),
1158            "Endpoint"
1159        );
1160        assert_eq!(
1161            flat.get("MetricData.member.1.Dimensions.member.1.Value")
1162                .unwrap(),
1163            "/api"
1164        );
1165    }
1166
1167    #[test]
1168    fn flatten_json_to_query_non_object_is_empty() {
1169        assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
1170        assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
1171    }
1172
1173    #[test]
1174    fn parse_amz_target_various_prefixes() {
1175        assert_eq!(
1176            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
1177            "sqs"
1178        );
1179        assert_eq!(
1180            parse_amz_target("AmazonSNS.Publish").unwrap().service,
1181            "sns"
1182        );
1183        assert_eq!(
1184            parse_amz_target("DynamoDB_20120810.GetItem")
1185                .unwrap()
1186                .service,
1187            "dynamodb"
1188        );
1189        assert_eq!(
1190            parse_amz_target("Logs_20140328.PutLogEvents")
1191                .unwrap()
1192                .service,
1193            "logs"
1194        );
1195        assert_eq!(
1196            parse_amz_target("secretsmanager.GetSecretValue")
1197                .unwrap()
1198                .service,
1199            "secretsmanager"
1200        );
1201        assert_eq!(
1202            parse_amz_target("TrentService.Encrypt").unwrap().service,
1203            "kms"
1204        );
1205        assert_eq!(
1206            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
1207                .unwrap()
1208                .service,
1209            "cognito-idp"
1210        );
1211        assert_eq!(
1212            parse_amz_target("AWSStepFunctions.StartExecution")
1213                .unwrap()
1214                .service,
1215            "states"
1216        );
1217        assert_eq!(
1218            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
1219                .unwrap()
1220                .service,
1221            "organizations"
1222        );
1223        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
1224    }
1225
1226    #[test]
1227    fn infer_service_from_action_maps_sts() {
1228        assert_eq!(
1229            infer_service_from_action("AssumeRole").as_deref(),
1230            Some("sts")
1231        );
1232        assert_eq!(
1233            infer_service_from_action("GetCallerIdentity").as_deref(),
1234            Some("sts")
1235        );
1236    }
1237
1238    #[test]
1239    fn infer_service_from_action_maps_iam() {
1240        assert_eq!(
1241            infer_service_from_action("CreateUser").as_deref(),
1242            Some("iam")
1243        );
1244        assert_eq!(
1245            infer_service_from_action("ListRoles").as_deref(),
1246            Some("iam")
1247        );
1248    }
1249
1250    #[test]
1251    fn infer_service_from_action_maps_ses() {
1252        assert_eq!(
1253            infer_service_from_action("SendEmail").as_deref(),
1254            Some("ses")
1255        );
1256        assert_eq!(
1257            infer_service_from_action("ListIdentities").as_deref(),
1258            Some("ses")
1259        );
1260    }
1261
1262    #[test]
1263    fn infer_service_from_action_maps_sns_confirmation_flow() {
1264        // SNS hands subscribers unsigned SubscribeURL / UnsubscribeUrl GETs,
1265        // so the service must be inferred from the action alone.
1266        assert_eq!(
1267            infer_service_from_action("ConfirmSubscription").as_deref(),
1268            Some("sns")
1269        );
1270        assert_eq!(
1271            infer_service_from_action("Unsubscribe").as_deref(),
1272            Some("sns")
1273        );
1274    }
1275
1276    #[test]
1277    fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1278        // Mirror the bare GET an HTTP/S subscriber issues at the SubscribeURL:
1279        // no Authorization header, bare-localhost Host, Action in the query.
1280        let mut headers = HeaderMap::new();
1281        headers.insert("host", "localhost:4566".parse().unwrap());
1282        let mut query_params = HashMap::new();
1283        query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1284        query_params.insert(
1285            "TopicArn".to_string(),
1286            "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1287        );
1288        query_params.insert("Token".to_string(), "abc123".to_string());
1289
1290        let detected = detect_service(&headers, &query_params, &Bytes::new())
1291            .expect("ConfirmSubscription must route to a service");
1292        assert_eq!(detected.service, "sns");
1293        assert_eq!(detected.action, "ConfirmSubscription");
1294        assert_eq!(detected.protocol, AwsProtocol::Query);
1295    }
1296
1297    #[test]
1298    fn infer_service_from_action_unknown_returns_none() {
1299        assert!(infer_service_from_action("NotARealAction").is_none());
1300    }
1301
1302    #[test]
1303    fn rest_protocol_for_returns_none_for_non_rest_service() {
1304        assert!(rest_protocol_for("sqs").is_none());
1305    }
1306
1307    #[test]
1308    fn url_decode_handles_percent_and_plus() {
1309        assert_eq!(url_decode("hello+world"), "hello world");
1310        assert_eq!(url_decode("hello%20world"), "hello world");
1311        assert_eq!(url_decode("100%25"), "100%");
1312    }
1313
1314    #[test]
1315    fn url_decode_ignores_malformed_percent() {
1316        assert_eq!(url_decode("%ZZ"), "");
1317    }
1318
1319    #[test]
1320    fn from_hex_valid_digits() {
1321        assert_eq!(from_hex(b'0'), Some(0));
1322        assert_eq!(from_hex(b'9'), Some(9));
1323        assert_eq!(from_hex(b'a'), Some(10));
1324        assert_eq!(from_hex(b'F'), Some(15));
1325    }
1326
1327    #[test]
1328    fn from_hex_invalid_returns_none() {
1329        assert!(from_hex(b'g').is_none());
1330        assert!(from_hex(b' ').is_none());
1331    }
1332
1333    #[test]
1334    fn detect_service_via_amz_target() {
1335        let mut headers = HeaderMap::new();
1336        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1337        let query = HashMap::new();
1338        let body = Bytes::new();
1339        let detected = detect_service(&headers, &query, &body).unwrap();
1340        assert_eq!(detected.service, "ssm");
1341        assert_eq!(detected.action, "GetParameter");
1342    }
1343
1344    #[test]
1345    fn detect_service_via_query_action_with_inferred_service() {
1346        let headers = HeaderMap::new();
1347        let mut query = HashMap::new();
1348        query.insert("Action".to_string(), "AssumeRole".to_string());
1349        let body = Bytes::new();
1350        let detected = detect_service(&headers, &query, &body).unwrap();
1351        assert_eq!(detected.service, "sts");
1352        assert_eq!(detected.action, "AssumeRole");
1353        assert_eq!(detected.protocol, AwsProtocol::Query);
1354    }
1355
1356    #[test]
1357    fn detect_service_via_form_body() {
1358        let headers = HeaderMap::new();
1359        let query = HashMap::new();
1360        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1361        let detected = detect_service(&headers, &query, &body).unwrap();
1362        assert_eq!(detected.service, "ses");
1363        assert_eq!(detected.action, "SendEmail");
1364    }
1365
1366    #[test]
1367    fn detect_service_via_sigv2_presigned() {
1368        let headers = HeaderMap::new();
1369        let mut query = HashMap::new();
1370        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1371        query.insert("Signature".to_string(), "sig".to_string());
1372        query.insert("Expires".to_string(), "1234567890".to_string());
1373        let body = Bytes::new();
1374        let detected = detect_service(&headers, &query, &body).unwrap();
1375        assert_eq!(detected.service, "s3");
1376        assert_eq!(detected.protocol, AwsProtocol::Rest);
1377    }
1378
1379    #[test]
1380    fn detect_service_via_sigv4_presigned_credential() {
1381        let headers = HeaderMap::new();
1382        let mut query = HashMap::new();
1383        query.insert(
1384            "X-Amz-Credential".to_string(),
1385            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1386        );
1387        let body = Bytes::new();
1388        let detected = detect_service(&headers, &query, &body).unwrap();
1389        assert_eq!(detected.service, "s3");
1390        assert_eq!(detected.protocol, AwsProtocol::Rest);
1391    }
1392
1393    #[test]
1394    fn detect_service_unknown_returns_none() {
1395        let headers = HeaderMap::new();
1396        let query = HashMap::new();
1397        let body = Bytes::new();
1398        assert!(detect_service(&headers, &query, &body).is_none());
1399    }
1400
1401    #[test]
1402    fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1403        // Real AWS API Gateway V2 SDK signs with `apigateway` per the
1404        // model's `endpointPrefix`, but Smithy-driven tooling (including
1405        // our conformance probe) sends `apigatewayv2`. Both routes resolve
1406        // to the same fakecloud service registry entry.
1407        assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1408    }
1409
1410    #[test]
1411    fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1412        // The bedrock-runtime credential scope shares path namespace with
1413        // the bedrock control plane (`POST /guardrail/{id}/version/{ver}/apply`
1414        // is implemented under BedrockService). Routing must resolve to
1415        // the bedrock service so the existing handlers run. See #1232.
1416        assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1417    }
1418
1419    #[test]
1420    fn normalize_service_name_passes_through_unaliased_services() {
1421        // Every service that isn't on the alias list must round-trip
1422        // unchanged — including the canonical bedrock name itself, so a
1423        // plain bedrock request takes the same code path it always has.
1424        assert_eq!(normalize_service_name("bedrock"), "bedrock");
1425        assert_eq!(normalize_service_name("s3"), "s3");
1426        assert_eq!(normalize_service_name("lambda"), "lambda");
1427        assert_eq!(normalize_service_name(""), "");
1428        assert_eq!(
1429            normalize_service_name("unknown-future-service"),
1430            "unknown-future-service"
1431        );
1432    }
1433
1434    #[test]
1435    fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1436        // SigV4 auth header carries `bedrock-runtime` in the credential
1437        // scope; dispatcher must route to the bedrock service handler so
1438        // `/guardrail/...` lands on `BedrockService` instead of falling
1439        // through to API Gateway.
1440        let mut headers = HeaderMap::new();
1441        headers.insert(
1442            "authorization",
1443            "AWS4-HMAC-SHA256 \
1444             Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1445             SignedHeaders=host, Signature=abc"
1446                .parse()
1447                .unwrap(),
1448        );
1449        let query = HashMap::new();
1450        let body = Bytes::new();
1451        let detected = detect_service(&headers, &query, &body).unwrap();
1452        assert_eq!(detected.service, "bedrock");
1453        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1454    }
1455
1456    #[test]
1457    fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1458        // Same alias normalization on the presigned-URL path: a request
1459        // signed with bedrock-runtime in the X-Amz-Credential query param
1460        // must still resolve to the bedrock service handler.
1461        let headers = HeaderMap::new();
1462        let mut query = HashMap::new();
1463        query.insert(
1464            "X-Amz-Credential".to_string(),
1465            "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1466        );
1467        let body = Bytes::new();
1468        let detected = detect_service(&headers, &query, &body).unwrap();
1469        assert_eq!(detected.service, "bedrock");
1470        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1471    }
1472
1473    #[test]
1474    fn parse_routing_host_localstack_basic() {
1475        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1476        assert_eq!(h.service, "sqs");
1477        assert_eq!(h.region, "us-east-1");
1478        assert!(h.bucket.is_none());
1479    }
1480
1481    #[test]
1482    fn parse_routing_host_localstack_with_port() {
1483        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1484        assert_eq!(h.service, "lambda");
1485        assert_eq!(h.region, "eu-west-1");
1486        assert!(h.bucket.is_none());
1487    }
1488
1489    #[test]
1490    fn parse_routing_host_case_insensitive() {
1491        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1492        assert_eq!(h.service, "sqs");
1493        assert_eq!(h.region, "us-east-1");
1494
1495        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1496        assert_eq!(h.service, "lambda");
1497        assert_eq!(h.region, "us-east-1");
1498    }
1499
1500    #[test]
1501    fn parse_routing_host_localstack_s3_virtual_hosted() {
1502        let h =
1503            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1504        assert_eq!(h.service, "s3");
1505        assert_eq!(h.region, "us-east-1");
1506        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1507    }
1508
1509    #[test]
1510    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1511        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1512        assert_eq!(h.service, "s3");
1513        assert_eq!(h.region, "us-east-1");
1514        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1515    }
1516
1517    #[test]
1518    fn parse_routing_host_aws_service_region() {
1519        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1520        assert_eq!(h.service, "sqs");
1521        assert_eq!(h.region, "us-east-1");
1522        assert!(h.bucket.is_none());
1523
1524        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1525        assert_eq!(h.service, "dynamodb");
1526        assert_eq!(h.region, "eu-west-2");
1527    }
1528
1529    #[test]
1530    fn parse_routing_host_aws_s3_path_style_modern() {
1531        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1532        assert_eq!(h.service, "s3");
1533        assert_eq!(h.region, "us-east-1");
1534        assert!(h.bucket.is_none());
1535    }
1536
1537    #[test]
1538    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1539        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1540        assert_eq!(h.service, "s3");
1541        assert_eq!(h.region, "us-east-1");
1542        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1543    }
1544
1545    #[test]
1546    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1547        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1548        assert_eq!(h.service, "s3");
1549        assert_eq!(h.region, "us-east-1");
1550        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1551    }
1552
1553    #[test]
1554    fn parse_routing_host_aws_s3_legacy_global() {
1555        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
1556        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
1557        let h = parse_routing_host("s3.amazonaws.com").unwrap();
1558        assert_eq!(h.service, "s3");
1559        assert_eq!(h.region, "us-east-1");
1560        assert!(h.bucket.is_none());
1561
1562        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1563        assert_eq!(h.service, "s3");
1564        assert_eq!(h.region, "us-east-1");
1565        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1566    }
1567
1568    #[test]
1569    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1570        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
1571        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
1572        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1573        assert_eq!(h.service, "s3");
1574        assert_eq!(h.region, "us-east-1");
1575        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1576    }
1577
1578    #[test]
1579    fn parse_routing_host_aws_s3_dash_separated() {
1580        // Older dash-separated form still served by AWS.
1581        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1582        assert_eq!(h.service, "s3");
1583        assert_eq!(h.region, "us-west-2");
1584        assert!(h.bucket.is_none());
1585
1586        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1587        assert_eq!(h.service, "s3");
1588        assert_eq!(h.region, "us-west-2");
1589        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1590    }
1591
1592    #[test]
1593    fn parse_routing_host_aws_s3_dualstack_and_fips() {
1594        for (host, bucket) in [
1595            (
1596                "my-bucket.s3.dualstack.us-east-1.amazonaws.com",
1597                Some("my-bucket"),
1598            ),
1599            ("a.b.s3.dualstack.eu-west-2.amazonaws.com", Some("a.b")),
1600            ("s3.dualstack.eu-west-2.amazonaws.com", None),
1601            (
1602                "my-bucket.s3-fips.us-gov-west-1.amazonaws.com",
1603                Some("my-bucket"),
1604            ),
1605            (
1606                "my-bucket.s3-fips.dualstack.us-east-1.amazonaws.com",
1607                Some("my-bucket"),
1608            ),
1609        ] {
1610            let h = parse_routing_host(host).unwrap();
1611            assert_eq!(h.service, "s3", "{host}");
1612            assert_eq!(h.bucket.as_deref(), bucket, "{host}");
1613        }
1614        let h = parse_routing_host("b.s3.dualstack.eu-west-2.amazonaws.com").unwrap();
1615        assert_eq!(h.region, "eu-west-2");
1616    }
1617
1618    #[test]
1619    fn parse_routing_host_aws_s3_website_endpoints() {
1620        // Dash form: the region is what follows `s3-website-`, not
1621        // `website-<region>` as the generic `s3-<region>` rule would read it.
1622        let h = parse_routing_host("site.s3-website-us-west-2.amazonaws.com").unwrap();
1623        assert_eq!(h.service, "s3");
1624        assert_eq!(h.region, "us-west-2");
1625        assert_eq!(h.bucket.as_deref(), Some("site"));
1626
1627        // Dot form (every region launched since 2014).
1628        let h = parse_routing_host("my.site.s3-website.eu-central-1.amazonaws.com").unwrap();
1629        assert_eq!(h.service, "s3");
1630        assert_eq!(h.region, "eu-central-1");
1631        assert_eq!(h.bucket.as_deref(), Some("my.site"));
1632    }
1633
1634    #[test]
1635    fn parse_routing_host_other_partition_suffixes() {
1636        let h = parse_routing_host("my-bucket.s3.cn-north-1.amazonaws.com.cn").unwrap();
1637        assert_eq!(h.service, "s3");
1638        assert_eq!(h.region, "cn-north-1");
1639        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1640
1641        let h = parse_routing_host("sqs.cn-northwest-1.amazonaws.com.cn").unwrap();
1642        assert_eq!(h.service, "sqs");
1643        assert_eq!(h.region, "cn-northwest-1");
1644
1645        let h = parse_routing_host("b.s3.us-iso-east-1.c2s.ic.gov").unwrap();
1646        assert_eq!(h.region, "us-iso-east-1");
1647        assert_eq!(h.bucket.as_deref(), Some("b"));
1648
1649        assert!(parse_routing_host("amazonaws.com.cn").is_none());
1650        assert!(parse_routing_host(".amazonaws.com.cn").is_none());
1651    }
1652
1653    #[test]
1654    fn parse_routing_host_rejects_plain_localhost() {
1655        assert!(parse_routing_host("localhost:4566").is_none());
1656        assert!(parse_routing_host("127.0.0.1:4566").is_none());
1657    }
1658
1659    #[test]
1660    fn parse_routing_host_rejects_unknown_suffix() {
1661        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1662        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1663    }
1664
1665    #[test]
1666    fn parse_routing_host_empty_and_malformed_rejected() {
1667        assert!(parse_routing_host("").is_none());
1668        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1669        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1670        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1671        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1672        assert!(parse_routing_host(".amazonaws.com").is_none());
1673        assert!(parse_routing_host("amazonaws.com").is_none());
1674    }
1675
1676    #[test]
1677    fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1678        // A single-label "s3-accesspoint" host has < 2 labels, so the
1679        // virtual-hosted `len() - 2` slice would underflow and panic without
1680        // the length guard. It must be rejected, not crash the router.
1681        assert!(parse_routing_host("s3-accesspoint").is_none());
1682    }
1683
1684    #[test]
1685    fn detect_service_via_host_for_rest_service() {
1686        let mut headers = HeaderMap::new();
1687        headers.insert(
1688            "host",
1689            "s3.us-east-1.localhost.localstack.cloud:4566"
1690                .parse()
1691                .unwrap(),
1692        );
1693        let query = HashMap::new();
1694        let body = Bytes::new();
1695        let detected = detect_service(&headers, &query, &body).unwrap();
1696        assert_eq!(detected.service, "s3");
1697        assert_eq!(detected.protocol, AwsProtocol::Rest);
1698    }
1699
1700    #[test]
1701    fn detect_service_via_host_for_rest_json_service() {
1702        let mut headers = HeaderMap::new();
1703        headers.insert(
1704            "host",
1705            "lambda.us-east-1.localhost.localstack.cloud:4566"
1706                .parse()
1707                .unwrap(),
1708        );
1709        let query = HashMap::new();
1710        let body = Bytes::new();
1711        let detected = detect_service(&headers, &query, &body).unwrap();
1712        assert_eq!(detected.service, "lambda");
1713        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1714    }
1715
1716    #[test]
1717    fn detect_service_via_host_plus_query_action() {
1718        let mut headers = HeaderMap::new();
1719        headers.insert(
1720            "host",
1721            "sqs.us-east-1.localhost.localstack.cloud:4566"
1722                .parse()
1723                .unwrap(),
1724        );
1725        let mut query = HashMap::new();
1726        query.insert("Action".to_string(), "ListQueues".to_string());
1727        let body = Bytes::new();
1728        let detected = detect_service(&headers, &query, &body).unwrap();
1729        assert_eq!(detected.service, "sqs");
1730        assert_eq!(detected.action, "ListQueues");
1731        assert_eq!(detected.protocol, AwsProtocol::Query);
1732    }
1733
1734    #[test]
1735    fn detect_service_sigv4_wins_over_host() {
1736        let mut headers = HeaderMap::new();
1737        headers.insert(
1738            "authorization",
1739            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1740             SignedHeaders=host, Signature=abc"
1741                .parse()
1742                .unwrap(),
1743        );
1744        headers.insert(
1745            "host",
1746            "lambda.us-east-1.localhost.localstack.cloud:4566"
1747                .parse()
1748                .unwrap(),
1749        );
1750        let query = HashMap::new();
1751        let body = Bytes::new();
1752        let detected = detect_service(&headers, &query, &body).unwrap();
1753        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
1754        assert_eq!(detected.service, "s3");
1755        assert_eq!(detected.protocol, AwsProtocol::Rest);
1756    }
1757
1758    #[test]
1759    fn detect_service_host_for_virtual_hosted_s3() {
1760        let mut headers = HeaderMap::new();
1761        headers.insert(
1762            "host",
1763            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1764                .parse()
1765                .unwrap(),
1766        );
1767        let query = HashMap::new();
1768        let body = Bytes::new();
1769        let detected = detect_service(&headers, &query, &body).unwrap();
1770        assert_eq!(detected.service, "s3");
1771        assert_eq!(detected.protocol, AwsProtocol::Rest);
1772    }
1773}