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    /// Smithy RPC v2 CBOR: `POST /service/{Service}/operation/{Operation}`
31    /// with `smithy-protocol: rpc-v2-cbor` and CBOR bodies. The request body is
32    /// decoded to the awsJson document and handled on the service's JSON path;
33    /// see [`crate::cbor`]. Used by: CloudWatch (current aws-sdk-rust).
34    RpcV2Cbor,
35}
36
37/// Pick the Query-family protocol for a resolved service. EC2 speaks the
38/// `ec2Query` variant (distinct error/response envelope); every other
39/// Action-param service uses plain `awsQuery`.
40fn query_protocol_for(service: &str) -> AwsProtocol {
41    if service == "ec2" {
42        AwsProtocol::Ec2Query
43    } else {
44        AwsProtocol::Query
45    }
46}
47
48/// Services that use REST protocol with XML responses (detected from SigV4 credential scope).
49const REST_XML_SERVICES: &[&str] = &["s3", "cloudfront", "route53"];
50
51/// Services that use REST protocol with JSON responses (detected from SigV4 credential scope).
52const REST_JSON_SERVICES: &[&str] = &[
53    "managedblockchain",
54    "lambda",
55    "ses",
56    "apigateway",
57    "bedrock",
58    "bedrock-agent",
59    "bedrock-agent-runtime",
60    "scheduler",
61    "batch",
62    "pipes",
63    "rds-data",
64    "dsql",
65    "resource-groups",
66    "eks",
67    "glacier",
68    "backup",
69    // AWS Resource Access Manager: restJson1 control plane (single-segment
70    // `@http` POST/DELETE URIs + JSON bodies).
71    "ram",
72    // Amazon S3 Tables: restJson1 control plane (path-labelled `@http` URIs
73    // over the table bucket -> namespace -> table hierarchy + JSON bodies).
74    "s3tables",
75    // AWS Lake Formation: restJson1 governance control plane over Glue
76    // (single-segment `@http` `POST /<Op>` URIs + JSON bodies).
77    "lakeformation",
78    // Amazon OpenSearch Service + Amazon Elasticsearch Service both sign as
79    // `es` and speak restJson1; one service handles both, splitting on the
80    // URL path version prefix.
81    "es",
82    "account",
83    // AWS AppConfig control plane + AppConfig Data plane both sign as
84    // `appconfig` and speak restJson1; one service handles both, splitting on
85    // the URL path (control `/applications/...` vs data `/configuration...`).
86    "appconfig",
87    // AWS CodeArtifact: restJson1 control plane (`@http` method + path routing,
88    // multi-value query params, JSON bodies).
89    "codeartifact",
90    // Amazon EFS: restJson1 control plane (path-labelled `@http` URIs over file
91    // systems, mount targets, access points; JSON bodies). Signs as
92    // `elasticfilesystem`.
93    "elasticfilesystem",
94    // Amazon MQ: restJson1 control plane (path-labelled `@http` URIs over
95    // brokers, configurations, users, and tags; JSON bodies). Signs as `mq`.
96    "mq",
97    // Amazon MSK (Managed Streaming for Apache Kafka): restJson1 control plane
98    // (path-labelled `@http` URIs over clusters, configurations, operations,
99    // replicators, VPC connections, and topics; JSON bodies). Signs as `kafka`.
100    "kafka",
101    // Amazon MWAA (Managed Workflows for Apache Airflow): restJson1 control
102    // plane (path-labelled `@http` URIs over environments, access tokens, and
103    // tags; JSON bodies). Signs as `airflow`, normalized to `mwaa`.
104    "mwaa",
105    // AWS Fault Injection Simulator: restJson1 control plane (path-labelled
106    // `@http` URIs over experiment templates, experiments, the actions and
107    // target-resource-type catalogs, target-account configurations, safety
108    // levers, and tags; JSON bodies). Signs as `fis`.
109    "fis",
110    // AWS X-Ray: restJson1 control plane + trace data plane (fixed `@http`
111    // `POST /<Op>` URIs over trace segments, the derived service graph,
112    // sampling rules, groups, encryption config, and tags; JSON bodies).
113    // Signs as `xray`.
114    "xray",
115    // AWS AppSync: restJson1 control plane + schema state (RESTful `@http`
116    // method + path routing over GraphQL APIs, data sources, resolvers,
117    // functions, types, caches, domain names, the Event-API surface, and
118    // source-API associations; JSON bodies). Signs as `appsync`.
119    "appsync",
120    // AWS Amplify: restJson1 hosting control plane (path-labelled `@http` URIs
121    // over apps, branches, domain associations, webhooks, backend
122    // environments, jobs/deployments, artifacts, and tags; JSON bodies).
123    // Signs as `amplify`.
124    "amplify",
125    // AWS Elemental MediaConvert: restJson1 video-transcoding control plane
126    // (path-labelled `@http` URIs under `/2017-08-29` over queues, presets, job
127    // templates, jobs, policy, endpoints, and tags; JSON bodies). Signs as
128    // `mediaconvert`.
129    "mediaconvert",
130    // AWS Serverless Application Repository: restJson1 control plane
131    // (path-labelled `@http` URIs under `/applications` over applications,
132    // versions, sharing policies, CloudFormation change sets/templates, and
133    // dependencies; JSON bodies). Signs as `serverlessrepo`.
134    "serverlessrepo",
135    // AWS IoT Data Plane: restJson1 device-shadow + retained-message data plane
136    // (path-labelled `@http` URIs over `/things/{thingName}/shadow`,
137    // `/topics/{topic}`, `/retainedMessage`, and `/connections`; raw
138    // `@httpPayload` shadow documents). Signs as `iotdata`.
139    "iotdata",
140    // Amazon Pinpoint: restJson1 control plane (RESTful `@http` method + path
141    // routing under `/v1/apps/...`, `/v1/templates/...`, `/v1/recommenders`,
142    // `/v1/tags/...` over apps, campaigns, segments, endpoints, channels,
143    // journeys, templates, jobs, event streams, recommenders, and tags; JSON
144    // bodies). Signs as `mobiletargeting`, normalized to `pinpoint`.
145    "pinpoint",
146    // AWS IoT Core control plane: restJson1 registry / jobs / rules / security
147    // control plane (path-labelled `@http` URIs over `/things/{thingName}`,
148    // `/policies/{policyName}`, `/jobs/{jobId}`, `/rules/{ruleName}`, ...).
149    // Signs as `iot`.
150    "iot",
151    // AWS IoT Wireless control plane: restJson1 LoRaWAN / Sidewalk registry
152    // (collection-POST creates + path-labelled reads over `/destinations`,
153    // `/wireless-devices/{Identifier}`, `/fuota-tasks/{Id}`, ...). Signs as
154    // `iotwireless`.
155    "iotwireless",
156];
157
158/// Whether the request is an S3 or API Gateway request on which a
159/// query-string `Action` is only a parameter, never a Query call: its `Host`
160/// is an S3 endpoint (path-style or a bucket's virtual host) or an
161/// execute-api host, its SigV4 scope (header or presign) is `s3` or
162/// `execute-api`, or it is an S3 SigV2 presign (`AWSAccessKeyId`,
163/// `Signature` and `Expires`; a SigV2 Query signature carries
164/// `SignatureVersion` and `Timestamp` instead). Dispatch adds the cases it
165/// alone can tell: an unsigned plain-host path addressing an existing bucket,
166/// and an API Gateway custom-domain host.
167pub fn query_action_is_a_parameter(
168    headers: &HeaderMap,
169    query_params: &HashMap<String, String>,
170) -> bool {
171    const PARAMETER_SERVICES: &[&str] = &["s3", "execute-api"];
172    let host = parse_routing_host_from_headers(headers)
173        .is_some_and(|h| PARAMETER_SERVICES.contains(&h.service.as_str()) || h.bucket.is_some())
174        || headers
175            .get(http::header::HOST)
176            .and_then(|v| v.to_str().ok())
177            .is_some_and(|h| h.contains(".execute-api."));
178    let scope = extract_service_from_auth(headers)
179        .or_else(|| {
180            let credential = query_params.get("X-Amz-Credential")?;
181            Some(normalize_service_name(credential.split('/').nth(3)?).to_string())
182        })
183        .is_some_and(|s| PARAMETER_SERVICES.contains(&s.as_str()));
184    let sigv2_presign = query_params.contains_key("AWSAccessKeyId")
185        && query_params.contains_key("Signature")
186        && query_params.contains_key("Expires")
187        && !query_params.contains_key("SignatureVersion");
188    host || scope || sigv2_presign
189}
190
191/// A query-string `Action` as a Query call: of the service the signature
192/// names, else the one the action names, else the one the `Host` names --
193/// unless the request is S3's or API Gateway's
194/// ([`query_action_is_a_parameter`]).
195fn query_string_action(
196    headers: &HeaderMap,
197    query_params: &HashMap<String, String>,
198) -> Option<DetectedRequest> {
199    let action = query_params.get("Action")?;
200    if query_action_is_a_parameter(headers, query_params) {
201        return None;
202    }
203    let service = extract_service_from_auth(headers)
204        .or_else(|| infer_service_from_action(action))
205        .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service))?;
206    let protocol = query_protocol_for(&service);
207    Some(DetectedRequest {
208        service,
209        action: action.clone(),
210        protocol,
211    })
212}
213
214/// Detected service name and action from an incoming HTTP request.
215#[derive(Debug, Clone)]
216pub struct DetectedRequest {
217    pub service: String,
218    pub action: String,
219    pub protocol: AwsProtocol,
220}
221
222/// Header-only service detection. Skips the form-encoded body sniff so
223/// the dispatch path can decide whether to stream or buffer the body
224/// without first reading it. Returns `None` when only a body sniff
225/// would succeed; the caller must then fall back to [`detect_service`]
226/// after buffering. Used to opt streaming routes (S3 PutObject /
227/// UploadPart, ECR OCI v2 blob upload) out of the global body cap.
228pub fn detect_service_headers_only(
229    headers: &HeaderMap,
230    query_params: &HashMap<String, String>,
231) -> Option<DetectedRequest> {
232    // Mirrors `detect_service` minus step 3 (form-body sniff).
233    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
234        return parse_amz_target(target);
235    }
236    if let Some(detected) = query_string_action(headers, query_params) {
237        return Some(detected);
238    }
239    if let Some(service) = extract_service_from_auth(headers) {
240        if let Some(protocol) = rest_protocol_for(&service) {
241            return Some(DetectedRequest {
242                service,
243                action: String::new(),
244                protocol,
245            });
246        }
247    }
248    if let Some(credential) = query_params.get("X-Amz-Credential") {
249        let parts: Vec<&str> = credential.split('/').collect();
250        if parts.len() >= 4 {
251            let service = normalize_service_name(parts[3]).to_string();
252            if let Some(protocol) = rest_protocol_for(&service) {
253                return Some(DetectedRequest {
254                    service,
255                    action: String::new(),
256                    protocol,
257                });
258            }
259        }
260    }
261    if query_params.contains_key("AWSAccessKeyId")
262        && query_params.contains_key("Signature")
263        && query_params.contains_key("Expires")
264    {
265        return Some(DetectedRequest {
266            service: "s3".to_string(),
267            action: String::new(),
268            protocol: AwsProtocol::Rest,
269        });
270    }
271    if let Some(host_info) = parse_routing_host_from_headers(headers) {
272        if let Some(protocol) = rest_protocol_for(&host_info.service) {
273            return Some(DetectedRequest {
274                service: host_info.service,
275                action: String::new(),
276                protocol,
277            });
278        }
279    }
280    None
281}
282
283/// Whether [`detect_service_headers_only`] names the service
284/// [`detect_service`] will route the request to, whatever its body: the
285/// header detection rests on an `X-Amz-Target`, a query `Action` that names
286/// a service, or a SigV4 `Authorization` scope, each of which `detect_service`
287/// honors ahead of (or, for the scope, inside) its form-body sniff. A
288/// detection resting on a presigned URL or the Host header alone is final
289/// only without a form body ([`is_form_content_type`]): a form body naming an
290/// `Action` routes the request to that action's service instead.
291pub fn headers_name_final_service(
292    headers: &HeaderMap,
293    query_params: &HashMap<String, String>,
294) -> bool {
295    !is_form_content_type(headers)
296        || headers.contains_key("x-amz-target")
297        || query_params.get("Action").is_some_and(|action| {
298            extract_service_from_auth(headers)
299                .or_else(|| infer_service_from_action(action))
300                .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service))
301                .is_some()
302        })
303        || extract_service_from_auth(headers).is_some()
304}
305
306/// Whether the request declares a form body
307/// (`application/x-www-form-urlencoded`, parameters such as `charset`
308/// allowed): the only body Query detection reads an `Action` from, before or
309/// after the body is read.
310pub fn is_form_content_type(headers: &HeaderMap) -> bool {
311    headers
312        .get(http::header::CONTENT_TYPE)
313        .and_then(|v| v.to_str().ok())
314        .and_then(|ct| ct.split(';').next())
315        .is_some_and(|media| {
316            media
317                .trim()
318                .eq_ignore_ascii_case("application/x-www-form-urlencoded")
319        })
320}
321
322/// Detect the target service and action from HTTP request components.
323pub fn detect_service(
324    headers: &HeaderMap,
325    query_params: &HashMap<String, String>,
326    body: &Bytes,
327) -> Option<DetectedRequest> {
328    // 1. Check X-Amz-Target header (JSON protocol)
329    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
330        return parse_amz_target(target);
331    }
332
333    // 2. A Query `Action` in the query string, for the service the request
334    //    names (see [`query_string_action`]).
335    if let Some(detected) = query_string_action(headers, query_params) {
336        return Some(detected);
337    }
338
339    // 3. Try a form-encoded body. Only a body declared as a form is one: real
340    //    AWS does not read `Action=...` from a `text/plain` (or untyped) body
341    //    as an API call at all.
342    if is_form_content_type(headers) {
343        let form_params = decode_form_urlencoded(body);
344
345        if let Some(action) = form_params.get("Action") {
346            let service = extract_service_from_auth(headers)
347                .or_else(|| infer_service_from_action(action))
348                .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
349            if let Some(service) = service {
350                let protocol = query_protocol_for(&service);
351                return Some(DetectedRequest {
352                    service,
353                    action: action.clone(),
354                    protocol,
355                });
356            }
357        }
358    }
359
360    // 4. Fallback: check auth header for REST-style services (S3, Lambda, SES, etc.)
361    if let Some(service) = extract_service_from_auth(headers) {
362        if let Some(protocol) = rest_protocol_for(&service) {
363            return Some(DetectedRequest {
364                service,
365                action: String::new(), // REST services determine action from method+path
366                protocol,
367            });
368        }
369    }
370
371    // 5. Check query params for presigned URL auth (X-Amz-Credential for SigV4)
372    if let Some(credential) = query_params.get("X-Amz-Credential") {
373        // Format: AKID/date/region/service/aws4_request
374        let parts: Vec<&str> = credential.split('/').collect();
375        if parts.len() >= 4 {
376            let service = normalize_service_name(parts[3]).to_string();
377            if let Some(protocol) = rest_protocol_for(&service) {
378                return Some(DetectedRequest {
379                    service,
380                    action: String::new(),
381                    protocol,
382                });
383            }
384        }
385    }
386
387    // 6. Check for SigV2-style presigned URL (AWSAccessKeyId + Signature + Expires)
388    //    Only match when all three SigV2 presigned-URL parameters are present so
389    //    we don't accidentally claim non-S3 requests.
390    if query_params.contains_key("AWSAccessKeyId")
391        && query_params.contains_key("Signature")
392        && query_params.contains_key("Expires")
393    {
394        return Some(DetectedRequest {
395            service: "s3".to_string(),
396            action: String::new(),
397            protocol: AwsProtocol::Rest,
398        });
399    }
400
401    // 7. Fallback: unsigned REST-style request carrying a LocalStack-shaped
402    //    Host header. Lets fixtures and curl-style probes reach the right
403    //    service without SigV4; signed requests were already handled in step 4.
404    if let Some(host_info) = parse_routing_host_from_headers(headers) {
405        if let Some(protocol) = rest_protocol_for(&host_info.service) {
406            return Some(DetectedRequest {
407                service: host_info.service,
408                action: String::new(),
409                protocol,
410            });
411        }
412    }
413
414    None
415}
416
417/// Service + region (and optional bucket) decoded from a `Host` header.
418/// Covers both the LocalStack hostname convention
419/// (`<service>.<region>.localhost.localstack.cloud[:port]`,
420/// `<bucket>.s3.<region>.localhost.localstack.cloud[:port]`) and real AWS
421/// service hostnames (`<service>.<region>.amazonaws.com`, S3 path-style
422/// and virtual-hosted-style including the legacy no-region
423/// `s3.amazonaws.com` / `<bucket>.s3.amazonaws.com` forms, the older
424/// dash-separated `s3-<region>.amazonaws.com` form, the dualstack, FIPS and
425/// static-website endpoints), under every AWS partition's DNS suffix.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct RoutingHost {
428    pub service: String,
429    pub region: String,
430    /// Set only for virtual-hosted-style S3 hostnames.
431    pub bucket: Option<String>,
432}
433
434const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
435
436/// The service an endpoint's leading label names. SES (v1 and v2) is served
437/// at `email.<region>`; every other label is the service's own name.
438fn host_label_service(label: &str) -> String {
439    match label {
440        "email" => "ses".to_string(),
441        other => other.to_string(),
442    }
443}
444
445/// Services whose commercial-partition endpoint is the region-less
446/// `<service>.amazonaws.com` (`s3.amazonaws.com` is handled with S3's other
447/// hosts).
448const GLOBAL_ENDPOINT_SERVICES: &[&str] = &["iam", "sts", "route53", "cloudfront"];
449
450/// Parse a `Host` header value for a LocalStack- or AWS-shaped hostname.
451/// Returns `None` for anything that doesn't match — callers fall through
452/// to their existing detection path.
453pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
454    let hostname = host.split(':').next()?;
455    if hostname.is_empty() {
456        return None;
457    }
458    let hostname = hostname.to_ascii_lowercase();
459    if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
460        return parse_localstack_prefix(prefix);
461    }
462    // The commercial partition's global endpoints carry no region
463    // (`route53.amazonaws.com`); they sign for us-east-1.
464    if let Some(service) = hostname
465        .strip_suffix(".amazonaws.com")
466        .filter(|s| GLOBAL_ENDPOINT_SERVICES.contains(s))
467    {
468        return Some(RoutingHost {
469            service: service.to_string(),
470            region: "us-east-1".to_string(),
471            bucket: None,
472        });
473    }
474    // Endpoint hostnames share one shape across partitions; only the DNS
475    // suffix differs, so accept every partition's.
476    fakecloud_aws::endpoint::DNS_SUFFIXES
477        .iter()
478        .find_map(|suffix| hostname.strip_suffix(suffix)?.strip_suffix('.'))
479        .and_then(parse_aws_prefix)
480}
481
482/// Whether the request is addressed to an S3 Control endpoint
483/// (`[<account-id>.]s3-control.<region>...`). S3 Control is served by the
484/// `s3` handler, so the host is the only thing telling the two apart. The
485/// `s3-control` label must lead the host (after the SDK's account-ID
486/// prefix), so a bucket whose name merely contains `s3-control` stays S3.
487pub fn is_s3_control_host(headers: &HeaderMap) -> bool {
488    let Some(host) = headers.get("host").and_then(|v| v.to_str().ok()) else {
489        return false;
490    };
491    let hostname = host.split(':').next().unwrap_or("").to_ascii_lowercase();
492    let labels: Vec<&str> = hostname.split('.').collect();
493    let control_label = match labels.as_slice() {
494        ["s3-control", ..] => true,
495        [account, "s3-control", ..] => {
496            account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit())
497        }
498        _ => false,
499    };
500    control_label && parse_routing_host(host).is_none_or(|h| h.bucket.is_none())
501}
502
503/// Normalize a `Host` header value for comparing against configured domain
504/// names: drop the port (`api.example.com:4566`), any trailing root dot, and
505/// lowercase it (DNS names are case-insensitive). A bracketed IPv6 literal
506/// keeps its brackets.
507pub fn normalize_host(host: &str) -> String {
508    let host = host.trim();
509    let without_port = if let Some(rest) = host.strip_prefix('[') {
510        match rest.find(']') {
511            Some(end) => &host[..end + 2],
512            None => host,
513        }
514    } else {
515        match host.rsplit_once(':') {
516            Some((name, port)) if port.chars().all(|c| c.is_ascii_digit()) => name,
517            _ => host,
518        }
519    };
520    without_port.trim_end_matches('.').to_ascii_lowercase()
521}
522
523/// The normalized `Host` header (see [`normalize_host`]), if present.
524pub fn normalized_host_from_headers(headers: &HeaderMap) -> Option<String> {
525    let host = headers.get("host")?.to_str().ok()?;
526    let host = normalize_host(host);
527    (!host.is_empty()).then_some(host)
528}
529
530/// Pull the `Host` header and parse it with [`parse_routing_host`].
531pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
532    let host = headers.get("host")?.to_str().ok()?;
533    parse_routing_host(host)
534}
535
536fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
537    if prefix.is_empty() {
538        return None;
539    }
540    let labels: Vec<&str> = prefix.split('.').collect();
541    if labels.iter().any(|l| l.is_empty()) {
542        return None;
543    }
544    // API Gateway execute-api hosts lead with the API id, not a service:
545    // `<api-id>.execute-api[.<region>]`. Without a region label the host
546    // carries no region at all, so let the caller fall back to its default
547    // rather than reading `execute-api` as one.
548    if let Some(host) = parse_execute_api_labels(&labels) {
549        return host;
550    }
551    match labels.len() {
552        2 => Some(RoutingHost {
553            service: host_label_service(labels[0]),
554            region: labels[1].to_string(),
555            bucket: None,
556        }),
557        n if n >= 3 && labels[n - 2] == "s3" => {
558            let bucket = labels[..n - 2].join(".");
559            Some(RoutingHost {
560                service: "s3".to_string(),
561                region: labels[n - 1].to_string(),
562                bucket: Some(bucket),
563            })
564        }
565        n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
566            let bucket = labels[..n - 2].join(".");
567            Some(RoutingHost {
568                service: "s3".to_string(),
569                region: labels[n - 1].to_string(),
570                bucket: Some(bucket),
571            })
572        }
573        n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
574            service: "s3".to_string(),
575            region: labels[n - 1].to_string(),
576            bucket: None,
577        }),
578        _ => None,
579    }
580}
581
582/// Classify an API Gateway execute-api host prefix (`<api-id>.execute-api`,
583/// optionally followed by `.<region>`). Returns `None` when the labels are not
584/// an execute-api host, `Some(None)` for one without a region, and
585/// `Some(Some(host))` for one naming its region.
586fn parse_execute_api_labels(labels: &[&str]) -> Option<Option<RoutingHost>> {
587    match labels {
588        [_api_id, "execute-api"] => Some(None),
589        [_api_id, "execute-api", region] => Some(Some(RoutingHost {
590            service: "execute-api".to_string(),
591            region: region.to_string(),
592            bucket: None,
593        })),
594        _ => None,
595    }
596}
597
598/// Parse the prefix before an AWS partition DNS suffix (`.amazonaws.com`, ...).
599///
600/// Handles every variant AWS has shipped for the common REST/Query services:
601///
602/// - `<service>.<region>` — modern regional endpoint (most services).
603/// - `s3.<region>` — modern path-style S3.
604/// - `<bucket>.s3.<region>` — modern virtual-hosted S3 (bucket may contain dots).
605/// - `s3` — legacy S3 global endpoint (implicitly `us-east-1`).
606/// - `<bucket>.s3` — legacy virtual-hosted S3 (implicitly `us-east-1`).
607/// - `s3-<region>` — older dash-separated path-style S3.
608/// - `<bucket>.s3-<region>` — older dash-separated virtual-hosted S3.
609/// - `[<bucket>.]s3.dualstack.<region>`, `[<bucket>.]s3-fips[.dualstack].<region>`
610///   — dualstack and FIPS S3 endpoints.
611/// - `<bucket>.s3-website-<region>` / `<bucket>.s3-website.<region>` — S3
612///   static-website endpoints.
613fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
614    if prefix.is_empty() {
615        return None;
616    }
617    let labels: Vec<&str> = prefix.split('.').collect();
618    if labels.iter().any(|l| l.is_empty()) {
619        return None;
620    }
621    let last = *labels.last()?;
622
623    // `<bucket>.s3-website-<region>`: dash-separated S3 static-website
624    // endpoint. Checked before the generic `s3-<region>` form below, which
625    // would otherwise read the region as `website-<region>`.
626    if let Some(region) = last.strip_prefix("s3-website-") {
627        if !region.is_empty() && labels.len() >= 2 {
628            return Some(RoutingHost {
629                service: "s3".to_string(),
630                region: region.to_string(),
631                bucket: Some(labels[..labels.len() - 1].join(".")),
632            });
633        }
634    }
635
636    // `s3-<region>` as the last label: dash-separated S3. Bucket, if any,
637    // is whatever precedes it.
638    if let Some(region) = last.strip_prefix("s3-") {
639        if !region.is_empty() {
640            let bucket = if labels.len() >= 2 {
641                Some(labels[..labels.len() - 1].join("."))
642            } else {
643                None
644            };
645            return Some(RoutingHost {
646                service: "s3".to_string(),
647                region: region.to_string(),
648                bucket,
649            });
650        }
651    }
652
653    // Legacy global S3: last label is `s3`, no region present. `s3` on its
654    // own is the path-style global endpoint; anything preceding it is the
655    // bucket (including dotted names like `a.b.s3.amazonaws.com`).
656    if last == "s3" {
657        if labels.len() == 1 {
658            return Some(RoutingHost {
659                service: "s3".to_string(),
660                region: "us-east-1".to_string(),
661                bucket: None,
662            });
663        }
664        return Some(RoutingHost {
665            service: "s3".to_string(),
666            region: "us-east-1".to_string(),
667            bucket: Some(labels[..labels.len() - 1].join(".")),
668        });
669    }
670
671    // `s3-accesspoint.<region>` — path-style access point endpoint.
672    // `{alias}-{account-id}.s3-accesspoint.<region>` — virtual-hosted access point.
673    if last == "s3-accesspoint" {
674        if labels.len() == 2 {
675            return Some(RoutingHost {
676                service: "s3".to_string(),
677                region: labels[0].to_string(),
678                bucket: None,
679            });
680        }
681        // Virtual-hosted form needs at least {alias}.{region}.s3-accesspoint, i.e.
682        // 3+ labels. A bare "s3-accesspoint" host (1 label) must not reach the
683        // `len() - 2` slice, which would underflow and panic.
684        if labels.len() >= 3 {
685            let bucket = labels[..labels.len() - 2].join(".");
686            return Some(RoutingHost {
687                service: "s3".to_string(),
688                region: labels[labels.len() - 1].to_string(),
689                bucket: Some(bucket),
690            });
691        }
692    }
693
694    // `s3-control.<region>` or `{account-id}.s3-control.<region>` — S3
695    // Control endpoint (access point management).
696    if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
697        return Some(RoutingHost {
698            service: "s3".to_string(),
699            region: last.to_string(),
700            bucket: None,
701        });
702    }
703
704    // Region-last S3 endpoints: `[<bucket>.]<endpoint>.<region>` where the
705    // endpoint is `s3`, `s3-fips`, either of those followed by `.dualstack`,
706    // or `s3-website` (the dot-separated static-website endpoint). Whatever
707    // precedes the endpoint is the bucket (dotted names included).
708    let before_region = &labels[..labels.len() - 1];
709    let (endpoint_labels, s3_endpoint) = match before_region {
710        [rest @ .., s3, "dualstack"] if matches!(*s3, "s3" | "s3-fips") => (rest, true),
711        [rest @ .., endpoint] if matches!(*endpoint, "s3" | "s3-fips" | "s3-website") => {
712            (rest, true)
713        }
714        _ => (before_region, false),
715    };
716    if s3_endpoint {
717        return Some(RoutingHost {
718            service: "s3".to_string(),
719            region: last.to_string(),
720            bucket: (!endpoint_labels.is_empty()).then(|| endpoint_labels.join(".")),
721        });
722    }
723
724    // `<api-id>.execute-api.<region>` — API Gateway's default endpoint.
725    if let Some(host) = parse_execute_api_labels(&labels) {
726        return host;
727    }
728
729    // `<service>.<region>` — the common case for every other service.
730    match labels.as_slice() {
731        [service, region] => Some(RoutingHost {
732            service: host_label_service(service),
733            region: region.to_string(),
734            bucket: None,
735        }),
736        _ => None,
737    }
738}
739
740/// Detect a Smithy RPC v2 CBOR request: the `smithy-protocol: rpc-v2-cbor`
741/// header plus a `/service/{ServiceName}/operation/{Operation}` path. The
742/// service shape name is the same identifier awsJson services use as their
743/// `X-Amz-Target` prefix, so it resolves through the same table.
744pub fn detect_rpc_v2_cbor(headers: &HeaderMap, path: &str) -> Option<DetectedRequest> {
745    let is_cbor = headers
746        .get(crate::cbor::SMITHY_PROTOCOL_HEADER)
747        .and_then(|v| v.to_str().ok())
748        .is_some_and(|v| v.trim().eq_ignore_ascii_case(crate::cbor::RPC_V2_CBOR));
749    if !is_cbor {
750        return None;
751    }
752    let (service_shape, operation) = crate::cbor::parse_rpc_v2_path(path)?;
753    let detected = parse_amz_target(&format!("{service_shape}.{operation}"))?;
754    Some(DetectedRequest {
755        protocol: AwsProtocol::RpcV2Cbor,
756        ..detected
757    })
758}
759
760/// Parse `X-Amz-Target: AWSEvents.PutEvents` -> service=events, action=PutEvents
761/// Parse `X-Amz-Target: AmazonSSM.GetParameter` -> service=ssm, action=GetParameter
762pub(crate) fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
763    let (prefix, action) = target.rsplit_once('.')?;
764
765    let service = match prefix {
766        "AWSEvents" => "events",
767        "AmazonSSM" => "ssm",
768        "AmazonSQS" => "sqs",
769        "AmazonSNS" => "sns",
770        "DynamoDB_20120810" => "dynamodb",
771        "DynamoDBStreams_20120810" => "dynamodbstreams",
772        "Logs_20140328" => "logs",
773        s if s.starts_with("secretsmanager") => "secretsmanager",
774        s if s.starts_with("TrentService") => "kms",
775        s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
776        s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
777        s if s.starts_with("Kinesis_20131202") => "kinesis",
778        s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
779        s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
780        s if s.starts_with("AWSStepFunctions") => "states",
781        s if s.starts_with("AWSOrganizationsV") => "organizations",
782        "CertificateManager" => "acm",
783        "ACMPrivateCA" => "acm-pca",
784        // Amazon Route 53 Resolver (resolver endpoints/rules, query logging, DNS
785        // Firewall): awsJson1_1. The service shape short name is the target
786        // prefix. Distinct from Route 53 (`route53`, a REST-XML service).
787        "Route53Resolver" => "route53resolver",
788        // AWS Config (config recorder / rules / compliance): awsJson1_1. The
789        // service shape short name is the target prefix.
790        "StarlingDoveService" => "config",
791        "AnyScaleFrontendService" => "application-autoscaling",
792        // Match the WAFv2 target version exactly so legacy WAF Classic
793        // (`AWSWAF_*` without the `_20190729` suffix) doesn't get routed here.
794        "AWSWAF_20190729" => "wafv2",
795        "AmazonAthena" => "athena",
796        s if s.starts_with("Firehose_") => "firehose",
797        "AWSGlue" => "glue",
798        // Amazon EMR (Elastic MapReduce): awsJson1.1. The service shape short
799        // name is the target prefix (`ElasticMapReduce.<Operation>`).
800        "ElasticMapReduce" => "emr",
801        // Amazon Textract (document text/analysis extraction): awsJson1_1. The
802        // service shape short name is the target prefix (`Textract.<Operation>`).
803        "Textract" => "textract",
804        // Amazon Transcribe: awsJson1.1. The service shape short name is the
805        // target prefix (`Transcribe.<Operation>`).
806        "Transcribe" => "transcribe",
807        // Amazon Translate: awsJson1_1. The service shape short name
808        // (`AWSShineFrontendService_20170701.<Operation>`) is the target prefix.
809        "AWSShineFrontendService_20170701" => "translate",
810        // AWS Shield / Shield Advanced: awsJson1_1. The service shape short
811        // name (`AWSShield_20160616.<Operation>`) is the target prefix.
812        "AWSShield_20160616" => "shield",
813        // Amazon Comprehend (NLP): awsJson1.1. The service shape name carries the
814        // dated version (`Comprehend_20171127.<Operation>`).
815        "Comprehend_20171127" => "comprehend",
816        // Amazon SWF (Simple Workflow Service): awsJson1_0. The service shape
817        // short name is the target prefix (`SimpleWorkflowService.<Operation>`).
818        "SimpleWorkflowService" => "swf",
819        // Amazon Timestream (Write + Query): awsJson1_0. BOTH the write and
820        // query SDK clients carry the SAME dated target prefix
821        // (`Timestream_20181101.<Operation>`); one fakecloud crate serves both.
822        "Timestream_20181101" => "timestream",
823        // AWS Support: awsJson1.1. The service shape carries the dated version
824        // (`AWSSupport_20130415.<Operation>`).
825        "AWSSupport_20130415" => "support",
826        "CloudApiService" => "cloudcontrolapi",
827        "ResourceGroupsTaggingAPI_20170126" => "tagging",
828        "AmazonMemoryDB" => "memorydb",
829        // Amazon Managed Service for Apache Flink (formerly Kinesis Data
830        // Analytics v2): awsJson1.1. The SigV4 signing name is
831        // `kinesisanalytics`; the internal fakecloud service key is
832        // `kinesisanalyticsv2`.
833        s if s.starts_with("KinesisAnalytics_20180523") => "kinesisanalyticsv2",
834        // Cloud Map (servicediscovery): awsJson1.1, target prefix carries the
835        // dated Route53 Auto Naming service version.
836        "Route53AutoNaming_v20170314" => "servicediscovery",
837        // Database Migration Service: awsJson1.1.
838        "AmazonDMSv20160101" => "dms",
839        // CloudTrail: awsJson1.1.
840        // aws-sdk-go-v2 / smithy clients (and terraform) send the short shape
841        // name; aws-sdk-go-v1 sends the fully-qualified form. Accept both.
842        "CloudTrail_20131101" => "cloudtrail",
843        "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
844        // Cost Explorer: awsJson1.1. aws-sdk / smithy clients (and terraform)
845        // send the short service-shape name; older clients may send the
846        // fully-qualified form. Accept both.
847        "AWSInsightsIndexService" => "ce",
848        "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
849        // Transfer Family: awsJson1.1.
850        "TransferService" => "transfer",
851        // AWS CodeBuild: awsJson1.1, target prefix is the dated service shape.
852        "CodeBuild_20161006" => "codebuild",
853        // AWS CodeCommit: awsJson1.1, target prefix is the dated service shape.
854        "CodeCommit_20150413" => "codecommit",
855        // Service Quotas: awsJson1.1, target prefix is the dated service shape.
856        "ServiceQuotasV20190624" => "servicequotas",
857        // IAM Identity Center Identity Store: awsJson1.1.
858        "AWSIdentityStore" => "identitystore",
859        // IAM Identity Center SSO Admin: awsJson1.1.
860        "SWBExternalService" => "sso",
861        // Verified Permissions: awsJson1.0.
862        "VerifiedPermissions" => "verifiedpermissions",
863        // CodeConnections (successor to CodeStar Connections): awsJson1.0.
864        "CodeConnections_20231201" => "codeconnections",
865        // Legacy CodeStar Connections API (same operations as CodeConnections);
866        // the terraform `aws_codestarconnections_connection` resource still
867        // signs with this dated prefix (note the lowercase `connections`, as
868        // emitted by the aws-sdk-go-v2 codestarconnections client), so route it
869        // to the same handler.
870        "CodeStar_connections_20191201" => "codeconnections",
871        // AWS CodeDeploy: awsJson1.1, target prefix is the dated service shape.
872        "CodeDeploy_20141006" => "codedeploy",
873        // AWS CodePipeline: awsJson1.1, target prefix is the dated service shape.
874        "CodePipeline_20150709" => "codepipeline",
875        // CloudWatch advertises awsJson1_0 (target service shape
876        // `GraniteServiceVersion20100801`) alongside the legacy awsQuery
877        // protocol. Newer SDKs (aws-sdk-rust / js-v3 / go-v2) POST with
878        // `X-Amz-Target: GraniteServiceVersion20100801.<Operation>` and a JSON
879        // body. The service registry key is `monitoring`.
880        s if s.starts_with("GraniteServiceVersion") => "monitoring",
881        // Amazon SageMaker: awsJson1.1. The service shape short name is the
882        // target prefix (`SageMaker.<Operation>`).
883        "SageMaker" => "sagemaker",
884        _ => return None,
885    };
886
887    Some(DetectedRequest {
888        service: service.to_string(),
889        action: action.to_string(),
890        protocol: AwsProtocol::Json,
891    })
892}
893
894/// Returns the REST protocol variant for a service, or None if not a REST service.
895/// Whether `service`'s API is a REST protocol (restXml or restJson1), so a
896/// REST request to its API host is one of its operations. ECR's API is
897/// awsJson: a REST request routed to ECR is its OCI registry.
898pub fn is_rest_api_service(service: &str) -> bool {
899    rest_protocol_for(service).is_some()
900}
901
902fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
903    if REST_XML_SERVICES.contains(&service) {
904        Some(AwsProtocol::Rest)
905    } else if REST_JSON_SERVICES.contains(&service) {
906        Some(AwsProtocol::RestJson)
907    } else {
908        None
909    }
910}
911
912/// Infer service from the action name when no SigV4 auth is present.
913/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
914/// do not require authentication and won't have an Authorization header.
915fn infer_service_from_action(action: &str) -> Option<String> {
916    match action {
917        "AssumeRole"
918        | "AssumeRoleWithSAML"
919        | "AssumeRoleWithWebIdentity"
920        | "GetCallerIdentity"
921        | "GetSessionToken"
922        | "GetFederationToken"
923        | "GetAccessKeyInfo"
924        | "DecodeAuthorizationMessage"
925        | "AssumeRoot"
926        | "GetWebIdentityToken"
927        | "GetDelegatedAccessToken" => Some("sts".to_string()),
928        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
929        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
930        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
931        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
932        // SES v1 (Query protocol)
933        "VerifyEmailIdentity"
934        | "VerifyDomainIdentity"
935        | "VerifyDomainDkim"
936        | "ListIdentities"
937        | "GetIdentityVerificationAttributes"
938        | "GetIdentityDkimAttributes"
939        | "DeleteIdentity"
940        | "SetIdentityDkimEnabled"
941        | "SetIdentityNotificationTopic"
942        | "SetIdentityFeedbackForwardingEnabled"
943        | "GetIdentityNotificationAttributes"
944        | "GetIdentityMailFromDomainAttributes"
945        | "SetIdentityMailFromDomain"
946        | "SendEmail"
947        | "SendRawEmail"
948        | "SendTemplatedEmail"
949        | "SendBulkTemplatedEmail"
950        | "CreateTemplate"
951        | "GetTemplate"
952        | "ListTemplates"
953        | "DeleteTemplate"
954        | "UpdateTemplate"
955        | "CreateConfigurationSet"
956        | "DeleteConfigurationSet"
957        | "DescribeConfigurationSet"
958        | "ListConfigurationSets"
959        | "CreateConfigurationSetEventDestination"
960        | "UpdateConfigurationSetEventDestination"
961        | "DeleteConfigurationSetEventDestination"
962        | "GetSendQuota"
963        | "GetSendStatistics"
964        | "GetAccountSendingEnabled"
965        | "CreateReceiptRuleSet"
966        | "DeleteReceiptRuleSet"
967        | "DescribeReceiptRuleSet"
968        | "ListReceiptRuleSets"
969        | "CloneReceiptRuleSet"
970        | "SetActiveReceiptRuleSet"
971        | "ReorderReceiptRuleSet"
972        | "CreateReceiptRule"
973        | "DeleteReceiptRule"
974        | "DescribeReceiptRule"
975        | "UpdateReceiptRule"
976        | "CreateReceiptFilter"
977        | "DeleteReceiptFilter"
978        | "ListReceiptFilters" => Some("ses".to_string()),
979        // SNS subscription handshake: the SubscribeURL / UnsubscribeUrl that SNS
980        // hands to HTTP/S and email subscribers are unsigned bare GETs (no auth
981        // header), so the service must be inferred from the action alone.
982        "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
983        // Any other Query operation name exactly one modeled Query service
984        // has (generated from aws-models).
985        _ => query_actions::QUERY_ACTION_SERVICES
986            .binary_search_by(|(a, _)| (*a).cmp(action))
987            .ok()
988            .map(|i| query_actions::QUERY_ACTION_SERVICES[i].1.to_string()),
989    }
990}
991
992#[path = "query_actions_gen.rs"]
993mod query_actions;
994
995/// Extract service name from the SigV4 Authorization header credential scope.
996pub(crate) fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
997    let auth = headers.get("authorization")?.to_str().ok()?;
998    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
999    Some(normalize_service_name(&info.service).to_string())
1000}
1001
1002/// Map AWS service-name aliases that share path namespace and handlers
1003/// to the canonical form used by fakecloud's service registry.
1004///
1005/// AWS uses `bedrock-runtime` in the SigV4 credential scope of runtime
1006/// API calls (`InvokeModel`, `ApplyGuardrail`, etc.) but the REST paths
1007/// (e.g. `POST /guardrail/{id}/version/{ver}/apply`) live under the same
1008/// `BedrockService` handler that owns the control-plane `bedrock` paths.
1009/// Without normalization, `detect_service` returns `None` for
1010/// `bedrock-runtime` (not in `REST_JSON_SERVICES`), the central
1011/// dispatcher falls back to API Gateway, and `/guardrail/...` 404s with
1012/// `NotFoundException: Stage not found: guardrail`. See issue #1232.
1013fn normalize_service_name(service: &str) -> &str {
1014    match service {
1015        "bedrock-runtime" => "bedrock",
1016        // Real AWS API Gateway V2 SDK signs with `apigateway` as the SigV4
1017        // service (per the model's `aws.api#service.endpointPrefix`), but
1018        // tools driven by the Smithy service shape name (including our own
1019        // conformance probe) may send `apigatewayv2`. Both refer to the
1020        // same fakecloud service registry entry — the v2 handler is path-
1021        // routed under `/v2/...` and the v1 handler under `/restapis/...`,
1022        // both reachable behind the `apigateway` SigV4 service.
1023        "apigatewayv2" => "apigateway",
1024        // Amazon OpenSearch Service has no dedicated SigV4 signing scope: its
1025        // SDK signs with `es` (the shared Elasticsearch Service scope), so a
1026        // real OpenSearch request already arrives as `es` and needs no
1027        // normalization. The conformance probe, however, signs with the
1028        // Smithy service shape name `opensearch`; alias it to `es` so both the
1029        // 2015 (Elasticsearch) and 2021 (OpenSearch) probes resolve to the one
1030        // registry entry, which then routes on the URL path version prefix.
1031        "opensearch" => "es",
1032        // AWS AppConfig Data signs with the shared `appconfig` scope, so a real
1033        // request already arrives as `appconfig`. The conformance probe signs
1034        // the data-plane operations with the Smithy service shape name
1035        // `appconfigdata`; alias it to `appconfig` so both model-services
1036        // resolve to the one registry entry, which routes on the URL path.
1037        "appconfigdata" => "appconfig",
1038        // Amazon MWAA signs SigV4 with the `airflow` scope (its ARN namespace),
1039        // so a real SDK request arrives as `airflow`. Alias it to the `mwaa`
1040        // registry entry (the conformance probe signs with the Smithy service
1041        // shape name `mwaa`, which already resolves).
1042        "airflow" => "mwaa",
1043        // Amazon Pinpoint signs SigV4 with the `mobiletargeting` scope (its ARN
1044        // namespace), so a real SDK request arrives as `mobiletargeting`. Alias
1045        // it to the `pinpoint` registry entry (the conformance probe signs with
1046        // the service-map `service_name`, `pinpoint`, which already resolves).
1047        "mobiletargeting" => "pinpoint",
1048        other => other,
1049    }
1050}
1051
1052/// Parse form-encoded body into key-value pairs.
1053pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
1054    decode_form_urlencoded(body)
1055}
1056
1057/// Flatten an awsJson request body into the flat `awsQuery` key form that
1058/// query-protocol handlers consume.
1059///
1060/// CloudWatch is served by handlers written against the awsQuery flat-key map
1061/// (`MetricData.member.1.MetricName`, `StatisticValues.Sum`,
1062/// `Dimensions.member.2.Value`, ...). Its Smithy model also advertises
1063/// `awsJson1_0`, so modern SDKs send a nested JSON body instead. Rather than
1064/// duplicate every parser, we flatten the JSON into the same map the awsQuery
1065/// handlers already read:
1066///
1067/// - object field `K` -> key `K` (or `<parent>.K` when nested in a struct)
1068/// - array element `i` (1-based) -> `<K>.member.<i>` (matching the awsQuery
1069///   list wire convention)
1070/// - scalars -> their string form (numbers/booleans stringified)
1071///
1072/// A body that is not a JSON object yields an empty map.
1073pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
1074    let mut out = HashMap::new();
1075    let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
1076        return out;
1077    };
1078    if value.is_object() {
1079        flatten_json_value("", &value, &mut out);
1080    }
1081    out
1082}
1083
1084fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
1085    match value {
1086        serde_json::Value::Object(map) => {
1087            for (k, v) in map {
1088                let child = if prefix.is_empty() {
1089                    k.clone()
1090                } else {
1091                    format!("{prefix}.{k}")
1092                };
1093                flatten_json_value(&child, v, out);
1094            }
1095        }
1096        serde_json::Value::Array(items) => {
1097            for (i, v) in items.iter().enumerate() {
1098                let child = format!("{prefix}.member.{}", i + 1);
1099                flatten_json_value(&child, v, out);
1100            }
1101        }
1102        serde_json::Value::Null => {}
1103        serde_json::Value::String(s) => {
1104            out.insert(prefix.to_string(), s.clone());
1105        }
1106        serde_json::Value::Bool(b) => {
1107            out.insert(prefix.to_string(), b.to_string());
1108        }
1109        serde_json::Value::Number(n) => {
1110            out.insert(prefix.to_string(), n.to_string());
1111        }
1112    }
1113}
1114
1115/// Decode a query / form-urlencoded string into ordered `(key, value)` pairs,
1116/// **preserving repeated keys**. The [`HashMap`] form
1117/// ([`decode_form_urlencoded`]) collapses `a=1&a=2` to a single entry, which
1118/// loses multi-value `@httpQuery` params; this variant keeps every occurrence
1119/// in wire order for callers that need them (e.g. list-style query params).
1120pub(crate) fn form_urlencoded_pairs(input: &str) -> Vec<(String, String)> {
1121    let mut pairs = Vec::new();
1122    for pair in input.split('&') {
1123        if pair.is_empty() {
1124            continue;
1125        }
1126        let (key, value) = match pair.find('=') {
1127            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1128            None => (pair, ""),
1129        };
1130        pairs.push((url_decode(key), url_decode(value)));
1131    }
1132    pairs
1133}
1134
1135fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
1136    let s = std::str::from_utf8(input).unwrap_or("");
1137    let mut result = HashMap::new();
1138    for pair in s.split('&') {
1139        if pair.is_empty() {
1140            continue;
1141        }
1142        let (key, value) = match pair.find('=') {
1143            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1144            None => (pair, ""),
1145        };
1146        result.insert(url_decode(key), url_decode(value));
1147    }
1148    result
1149}
1150
1151/// Decode one `application/x-www-form-urlencoded` component (a query-string
1152/// key or value, or a form-body field): `+` is a space and `%XX` escapes are
1153/// decoded on the raw bytes, then read as UTF-8 (lossily). For URI path
1154/// segments, where `+` is literal, use [`crate::path::percent_decode_segment`].
1155pub fn url_decode(input: &str) -> String {
1156    // Accumulate the decoded RAW BYTES first, then interpret the whole buffer
1157    // as UTF-8. Decoding each `%XX` byte straight into a `char` would treat it
1158    // as a Unicode codepoint (Latin-1), which corrupts multi-byte UTF-8
1159    // sequences (e.g. "caf%C3%A9" -> "café" instead of "café"). Reassembling
1160    // the bytes lets multi-byte sequences round-trip correctly.
1161    let mut buf: Vec<u8> = Vec::with_capacity(input.len());
1162    let mut bytes = input.bytes();
1163    while let Some(b) = bytes.next() {
1164        match b {
1165            b'+' => buf.push(b' '),
1166            b'%' => {
1167                let high = bytes.next().and_then(from_hex);
1168                let low = bytes.next().and_then(from_hex);
1169                // A well-formed `%XX` escape decodes to a single raw byte.
1170                // A malformed escape is dropped (best-effort, panic-free),
1171                // matching the prior behaviour.
1172                if let (Some(h), Some(l)) = (high, low) {
1173                    buf.push((h << 4) | l);
1174                }
1175            }
1176            _ => buf.push(b),
1177        }
1178    }
1179    String::from_utf8_lossy(&buf).into_owned()
1180}
1181
1182fn from_hex(b: u8) -> Option<u8> {
1183    match b {
1184        b'0'..=b'9' => Some(b - b'0'),
1185        b'a'..=b'f' => Some(b - b'a' + 10),
1186        b'A'..=b'F' => Some(b - b'A' + 10),
1187        _ => None,
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    #[test]
1196    fn headers_settle_the_service_only_when_no_form_body_can_change_it() {
1197        // An unsigned POST whose Host says S3 but whose form body names an
1198        // STS action is routed to STS: header detection alone is not final.
1199        let mut plain_host = HeaderMap::new();
1200        plain_host.insert("host", "b.s3.us-east-1.amazonaws.com".parse().unwrap());
1201        let mut host_only = plain_host.clone();
1202        host_only.insert(
1203            "content-type",
1204            "application/x-www-form-urlencoded; charset=utf-8"
1205                .parse()
1206                .unwrap(),
1207        );
1208        let none = HashMap::new();
1209        let body = Bytes::from("Action=GetCallerIdentity&Version=2011-06-15");
1210        assert_eq!(
1211            detect_service_headers_only(&host_only, &none).map(|d| d.service),
1212            Some("s3".to_string())
1213        );
1214        assert_eq!(
1215            detect_service(&host_only, &none, &body).map(|d| d.service),
1216            Some("sts".to_string())
1217        );
1218        assert!(!headers_name_final_service(&host_only, &none));
1219        // Without a form Content-Type the body is no Query call (real AWS does
1220        // not treat it as one), so the Host settles it.
1221        assert_eq!(
1222            detect_service(&plain_host, &none, &body).map(|d| d.service),
1223            Some("s3".to_string())
1224        );
1225        assert!(headers_name_final_service(&plain_host, &none));
1226        let mut text = plain_host.clone();
1227        text.insert("content-type", "text/plain".parse().unwrap());
1228        assert!(headers_name_final_service(&text, &none));
1229        // Likewise a presigned URL: its scope is not the Authorization header.
1230        let presigned: HashMap<String, String> = [(
1231            "X-Amz-Credential".to_string(),
1232            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1233        )]
1234        .into();
1235        let mut form = HeaderMap::new();
1236        form.insert(
1237            "content-type",
1238            "application/x-www-form-urlencoded".parse().unwrap(),
1239        );
1240        assert!(!headers_name_final_service(&form, &presigned));
1241
1242        // A SigV4 scope, a target or a resolvable query Action settles it.
1243        let mut signed = host_only.clone();
1244        signed.insert(
1245            "authorization",
1246            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1247             SignedHeaders=host, Signature=00"
1248                .parse()
1249                .unwrap(),
1250        );
1251        assert!(headers_name_final_service(&signed, &none));
1252        assert_eq!(
1253            detect_service(&signed, &none, &body).map(|d| d.service),
1254            Some("s3".to_string())
1255        );
1256        let mut target = HeaderMap::new();
1257        target.insert(
1258            "x-amz-target",
1259            "DynamoDB_20120810.ListTables".parse().unwrap(),
1260        );
1261        assert!(headers_name_final_service(&target, &none));
1262        let action: HashMap<String, String> =
1263            [("Action".to_string(), "GetCallerIdentity".to_string())].into();
1264        assert!(headers_name_final_service(&HeaderMap::new(), &action));
1265    }
1266
1267    #[test]
1268    fn s3_control_host_requires_leading_control_label() {
1269        let is_control = |host: &str| {
1270            let mut headers = HeaderMap::new();
1271            headers.insert("host", host.parse().unwrap());
1272            is_s3_control_host(&headers)
1273        };
1274        assert!(is_control("s3-control.us-east-1.amazonaws.com"));
1275        assert!(is_control(
1276            "123456789012.s3-control.us-east-1.amazonaws.com"
1277        ));
1278        assert!(is_control(
1279            "000000000000.s3-control.us-east-1.localhost.localstack.cloud:4566"
1280        ));
1281        assert!(is_control("123456789012.s3-control.localhost"));
1282        assert!(!is_control("my-s3-control-logs.s3.us-east-1.amazonaws.com"));
1283        assert!(!is_control(
1284            "my-s3-control-logs.s3.us-east-1.localhost.localstack.cloud:4566"
1285        ));
1286        assert!(!is_control("a.s3-control.b.s3.us-east-1.amazonaws.com"));
1287        assert!(!is_control("localhost:4566"));
1288    }
1289
1290    #[test]
1291    fn form_urlencoded_pairs_preserves_repeated_keys() {
1292        // The HashMap decoder collapses repeats; the ordered-pairs variant must
1293        // keep every occurrence in wire order (1.45 multi-value @httpQuery).
1294        let pairs = form_urlencoded_pairs("Id=a&Id=b&Id=c&Other=x");
1295        let ids: Vec<&str> = pairs
1296            .iter()
1297            .filter(|(k, _)| k == "Id")
1298            .map(|(_, v)| v.as_str())
1299            .collect();
1300        assert_eq!(ids, vec!["a", "b", "c"]);
1301        // Sanity: the HashMap form keeps only the last value.
1302        assert_eq!(
1303            decode_form_urlencoded(b"Id=a&Id=b&Id=c").get("Id").unwrap(),
1304            "c"
1305        );
1306    }
1307
1308    #[test]
1309    fn form_urlencoded_pairs_decodes_percent_and_plus() {
1310        let pairs = form_urlencoded_pairs("q=caf%C3%A9+bar&empty=");
1311        assert_eq!(pairs[0], ("q".to_string(), "café bar".to_string()));
1312        assert_eq!(pairs[1], ("empty".to_string(), String::new()));
1313    }
1314
1315    #[test]
1316    fn parse_amz_target_events() {
1317        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
1318        assert_eq!(result.service, "events");
1319        assert_eq!(result.action, "PutEvents");
1320        assert_eq!(result.protocol, AwsProtocol::Json);
1321    }
1322
1323    #[test]
1324    fn parse_amz_target_ssm() {
1325        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
1326        assert_eq!(result.service, "ssm");
1327        assert_eq!(result.action, "GetParameter");
1328    }
1329
1330    #[test]
1331    fn parse_amz_target_kinesis() {
1332        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
1333        assert_eq!(result.service, "kinesis");
1334        assert_eq!(result.action, "ListStreams");
1335        assert_eq!(result.protocol, AwsProtocol::Json);
1336    }
1337
1338    #[test]
1339    fn parse_query_body_basic() {
1340        let body = Bytes::from(
1341            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
1342        );
1343        let params = parse_query_body(&body);
1344        assert_eq!(params.get("Action").unwrap(), "SendMessage");
1345        assert_eq!(params.get("MessageBody").unwrap(), "hello");
1346    }
1347
1348    #[test]
1349    fn parse_query_body_empty_returns_empty_map() {
1350        let body = Bytes::from("");
1351        let params = parse_query_body(&body);
1352        assert!(params.is_empty());
1353    }
1354
1355    #[test]
1356    fn parse_query_body_duplicate_keys_last_wins() {
1357        let body = Bytes::from("key=a&key=b");
1358        let params = parse_query_body(&body);
1359        assert_eq!(params.get("key").unwrap(), "b");
1360    }
1361
1362    #[test]
1363    fn parse_query_body_single_key() {
1364        let body = Bytes::from("key=value");
1365        let params = parse_query_body(&body);
1366        assert_eq!(params.get("key").unwrap(), "value");
1367    }
1368
1369    #[test]
1370    fn url_decode_plain_ascii() {
1371        assert_eq!(url_decode("hello"), "hello");
1372        assert_eq!(url_decode("Action=SendMessage"), "Action=SendMessage");
1373    }
1374
1375    #[test]
1376    fn url_decode_plus_is_space() {
1377        assert_eq!(url_decode("hello+world"), "hello world");
1378        assert_eq!(url_decode("a+b+c"), "a b c");
1379    }
1380
1381    #[test]
1382    fn url_decode_multibyte_utf8_accents() {
1383        // "café" -> the é is UTF-8 0xC3 0xA9, two %-escapes for one codepoint.
1384        assert_eq!(url_decode("caf%C3%A9"), "café");
1385    }
1386
1387    #[test]
1388    fn url_decode_multibyte_utf8_cjk() {
1389        // "日本" (each char is 3 UTF-8 bytes).
1390        assert_eq!(url_decode("%E6%97%A5%E6%9C%AC"), "日本");
1391    }
1392
1393    #[test]
1394    fn url_decode_multibyte_utf8_emoji() {
1395        // "🚀" is a 4-byte UTF-8 sequence (F0 9F 9A 80).
1396        assert_eq!(url_decode("%F0%9F%9A%80"), "🚀");
1397    }
1398
1399    #[test]
1400    fn url_decode_mixed_ascii_and_multibyte() {
1401        assert_eq!(url_decode("Tag+%3D+caf%C3%A9%21"), "Tag = café!");
1402    }
1403
1404    #[test]
1405    fn url_decode_malformed_percent_is_graceful() {
1406        // A malformed escape is dropped best-effort and must never panic.
1407        assert_eq!(url_decode("100%"), "100");
1408        assert_eq!(url_decode("a%zz"), "a");
1409        assert_eq!(url_decode("a%4"), "a");
1410        // Preceding and following ASCII are preserved either side of the drop.
1411        assert_eq!(url_decode("x%y"), "x");
1412    }
1413
1414    #[test]
1415    fn url_decode_invalid_utf8_bytes_are_lossy_no_panic() {
1416        // 0xFF is not valid UTF-8; must not panic, replaced lossily.
1417        let out = url_decode("bad%FFbyte");
1418        assert!(out.starts_with("bad"));
1419        assert!(out.ends_with("byte"));
1420    }
1421
1422    #[test]
1423    fn parse_query_body_multibyte_value_round_trips() {
1424        let body = Bytes::from("Tag.Value=caf%C3%A9&Name=%E6%97%A5%E6%9C%AC");
1425        let params = parse_query_body(&body);
1426        assert_eq!(params.get("Tag.Value").unwrap(), "café");
1427        assert_eq!(params.get("Name").unwrap(), "日本");
1428    }
1429
1430    #[test]
1431    fn parse_amz_target_ecs() {
1432        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
1433        assert_eq!(result.service, "ecs");
1434        assert_eq!(result.action, "ListClusters");
1435        assert_eq!(result.protocol, AwsProtocol::Json);
1436    }
1437
1438    #[test]
1439    fn parse_amz_target_invalid_returns_none() {
1440        assert!(parse_amz_target("NoDotHere").is_none());
1441        assert!(parse_amz_target("").is_none());
1442    }
1443
1444    #[test]
1445    fn parse_amz_target_cloudwatch_json() {
1446        // CloudWatch's awsJson1_0 target service shape.
1447        let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
1448        assert_eq!(result.service, "monitoring");
1449        assert_eq!(result.action, "PutMetricData");
1450        assert_eq!(result.protocol, AwsProtocol::Json);
1451    }
1452
1453    #[test]
1454    fn flatten_json_to_query_nested() {
1455        let body = Bytes::from(
1456            serde_json::json!({
1457                "Namespace": "MyApp",
1458                "MetricData": [{
1459                    "MetricName": "Latency",
1460                    "Value": 12.5,
1461                    "StatisticValues": {"SampleCount": 3, "Sum": 10},
1462                    "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
1463                }]
1464            })
1465            .to_string(),
1466        );
1467        let flat = flatten_json_to_query(&body);
1468        assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
1469        assert_eq!(
1470            flat.get("MetricData.member.1.MetricName").unwrap(),
1471            "Latency"
1472        );
1473        assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
1474        assert_eq!(
1475            flat.get("MetricData.member.1.StatisticValues.SampleCount")
1476                .unwrap(),
1477            "3"
1478        );
1479        assert_eq!(
1480            flat.get("MetricData.member.1.Dimensions.member.1.Name")
1481                .unwrap(),
1482            "Endpoint"
1483        );
1484        assert_eq!(
1485            flat.get("MetricData.member.1.Dimensions.member.1.Value")
1486                .unwrap(),
1487            "/api"
1488        );
1489    }
1490
1491    #[test]
1492    fn flatten_json_to_query_non_object_is_empty() {
1493        assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
1494        assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
1495    }
1496
1497    #[test]
1498    fn parse_amz_target_various_prefixes() {
1499        assert_eq!(
1500            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
1501            "sqs"
1502        );
1503        assert_eq!(
1504            parse_amz_target("AmazonSNS.Publish").unwrap().service,
1505            "sns"
1506        );
1507        assert_eq!(
1508            parse_amz_target("DynamoDB_20120810.GetItem")
1509                .unwrap()
1510                .service,
1511            "dynamodb"
1512        );
1513        assert_eq!(
1514            parse_amz_target("Logs_20140328.PutLogEvents")
1515                .unwrap()
1516                .service,
1517            "logs"
1518        );
1519        assert_eq!(
1520            parse_amz_target("secretsmanager.GetSecretValue")
1521                .unwrap()
1522                .service,
1523            "secretsmanager"
1524        );
1525        assert_eq!(
1526            parse_amz_target("TrentService.Encrypt").unwrap().service,
1527            "kms"
1528        );
1529        assert_eq!(
1530            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
1531                .unwrap()
1532                .service,
1533            "cognito-idp"
1534        );
1535        assert_eq!(
1536            parse_amz_target("AWSStepFunctions.StartExecution")
1537                .unwrap()
1538                .service,
1539            "states"
1540        );
1541        assert_eq!(
1542            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
1543                .unwrap()
1544                .service,
1545            "organizations"
1546        );
1547        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
1548    }
1549
1550    #[test]
1551    fn infer_service_from_action_maps_sts() {
1552        assert_eq!(
1553            infer_service_from_action("AssumeRole").as_deref(),
1554            Some("sts")
1555        );
1556        assert_eq!(
1557            infer_service_from_action("GetCallerIdentity").as_deref(),
1558            Some("sts")
1559        );
1560        // Any Query operation name one modeled service has routes too.
1561        for (action, service) in [
1562            ("ListTopics", "sns"),
1563            ("ListQueues", "sqs"),
1564            ("DescribeRegions", "ec2"),
1565            ("DescribeStacks", "cloudformation"),
1566            ("DescribeLoadBalancers", "elasticloadbalancing"),
1567        ] {
1568            assert_eq!(
1569                infer_service_from_action(action).as_deref(),
1570                Some(service),
1571                "{action}"
1572            );
1573        }
1574        // A name several services share does not.
1575        assert_eq!(infer_service_from_action("ListTagsForResource"), None);
1576        // Every STS operation routes an unsigned form POST on the plain host.
1577        for action in [
1578            "AssumeRoot",
1579            "GetWebIdentityToken",
1580            "GetDelegatedAccessToken",
1581        ] {
1582            assert_eq!(infer_service_from_action(action).as_deref(), Some("sts"));
1583        }
1584    }
1585
1586    #[test]
1587    fn infer_service_from_action_maps_iam() {
1588        assert_eq!(
1589            infer_service_from_action("CreateUser").as_deref(),
1590            Some("iam")
1591        );
1592        assert_eq!(
1593            infer_service_from_action("ListRoles").as_deref(),
1594            Some("iam")
1595        );
1596    }
1597
1598    #[test]
1599    fn infer_service_from_action_maps_ses() {
1600        assert_eq!(
1601            infer_service_from_action("SendEmail").as_deref(),
1602            Some("ses")
1603        );
1604        assert_eq!(
1605            infer_service_from_action("ListIdentities").as_deref(),
1606            Some("ses")
1607        );
1608    }
1609
1610    #[test]
1611    fn infer_service_from_action_maps_sns_confirmation_flow() {
1612        // SNS hands subscribers unsigned SubscribeURL / UnsubscribeUrl GETs,
1613        // so the service must be inferred from the action alone.
1614        assert_eq!(
1615            infer_service_from_action("ConfirmSubscription").as_deref(),
1616            Some("sns")
1617        );
1618        assert_eq!(
1619            infer_service_from_action("Unsubscribe").as_deref(),
1620            Some("sns")
1621        );
1622    }
1623
1624    #[test]
1625    fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1626        // Mirror the bare GET an HTTP/S subscriber issues at the SubscribeURL:
1627        // no Authorization header, bare-localhost Host, Action in the query.
1628        let mut headers = HeaderMap::new();
1629        headers.insert("host", "localhost:4566".parse().unwrap());
1630        let mut query_params = HashMap::new();
1631        query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1632        query_params.insert(
1633            "TopicArn".to_string(),
1634            "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1635        );
1636        query_params.insert("Token".to_string(), "abc123".to_string());
1637
1638        let detected = detect_service(&headers, &query_params, &Bytes::new())
1639            .expect("ConfirmSubscription must route to a service");
1640        assert_eq!(detected.service, "sns");
1641        assert_eq!(detected.action, "ConfirmSubscription");
1642        assert_eq!(detected.protocol, AwsProtocol::Query);
1643    }
1644
1645    #[test]
1646    fn infer_service_from_action_unknown_returns_none() {
1647        assert!(infer_service_from_action("NotARealAction").is_none());
1648    }
1649
1650    #[test]
1651    fn rest_protocol_for_returns_none_for_non_rest_service() {
1652        assert!(rest_protocol_for("sqs").is_none());
1653    }
1654
1655    #[test]
1656    fn url_decode_handles_percent_and_plus() {
1657        assert_eq!(url_decode("hello+world"), "hello world");
1658        assert_eq!(url_decode("hello%20world"), "hello world");
1659        assert_eq!(url_decode("100%25"), "100%");
1660    }
1661
1662    #[test]
1663    fn url_decode_ignores_malformed_percent() {
1664        assert_eq!(url_decode("%ZZ"), "");
1665    }
1666
1667    #[test]
1668    fn from_hex_valid_digits() {
1669        assert_eq!(from_hex(b'0'), Some(0));
1670        assert_eq!(from_hex(b'9'), Some(9));
1671        assert_eq!(from_hex(b'a'), Some(10));
1672        assert_eq!(from_hex(b'F'), Some(15));
1673    }
1674
1675    #[test]
1676    fn from_hex_invalid_returns_none() {
1677        assert!(from_hex(b'g').is_none());
1678        assert!(from_hex(b' ').is_none());
1679    }
1680
1681    #[test]
1682    fn detect_service_via_amz_target() {
1683        let mut headers = HeaderMap::new();
1684        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1685        let query = HashMap::new();
1686        let body = Bytes::new();
1687        let detected = detect_service(&headers, &query, &body).unwrap();
1688        assert_eq!(detected.service, "ssm");
1689        assert_eq!(detected.action, "GetParameter");
1690    }
1691
1692    #[test]
1693    fn detect_service_via_query_action_with_inferred_service() {
1694        let headers = HeaderMap::new();
1695        let mut query = HashMap::new();
1696        query.insert("Action".to_string(), "AssumeRole".to_string());
1697        let body = Bytes::new();
1698        let detected = detect_service(&headers, &query, &body).unwrap();
1699        assert_eq!(detected.service, "sts");
1700        assert_eq!(detected.action, "AssumeRole");
1701        assert_eq!(detected.protocol, AwsProtocol::Query);
1702    }
1703
1704    #[test]
1705    fn detect_service_via_form_body() {
1706        let mut headers = HeaderMap::new();
1707        headers.insert(
1708            "content-type",
1709            "Application/X-WWW-Form-Urlencoded ; charset=UTF-8"
1710                .parse()
1711                .unwrap(),
1712        );
1713        let query = HashMap::new();
1714        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1715        let detected = detect_service(&headers, &query, &body).unwrap();
1716        assert_eq!(detected.service, "ses");
1717        assert_eq!(detected.action, "SendEmail");
1718        // The same body declared as anything else, or untyped, is no Query
1719        // call.
1720        for content_type in [None, Some("text/plain"), Some("application/json")] {
1721            let mut headers = HeaderMap::new();
1722            if let Some(ct) = content_type {
1723                headers.insert("content-type", ct.parse().unwrap());
1724            }
1725            assert!(
1726                detect_service(&headers, &query, &body).is_none(),
1727                "{content_type:?}"
1728            );
1729        }
1730    }
1731
1732    #[test]
1733    fn detect_service_via_sigv2_presigned() {
1734        let headers = HeaderMap::new();
1735        let mut query = HashMap::new();
1736        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1737        query.insert("Signature".to_string(), "sig".to_string());
1738        query.insert("Expires".to_string(), "1234567890".to_string());
1739        let body = Bytes::new();
1740        let detected = detect_service(&headers, &query, &body).unwrap();
1741        assert_eq!(detected.service, "s3");
1742        assert_eq!(detected.protocol, AwsProtocol::Rest);
1743    }
1744
1745    #[test]
1746    fn detect_service_via_sigv4_presigned_credential() {
1747        let headers = HeaderMap::new();
1748        let mut query = HashMap::new();
1749        query.insert(
1750            "X-Amz-Credential".to_string(),
1751            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1752        );
1753        let body = Bytes::new();
1754        let detected = detect_service(&headers, &query, &body).unwrap();
1755        assert_eq!(detected.service, "s3");
1756        assert_eq!(detected.protocol, AwsProtocol::Rest);
1757    }
1758
1759    #[test]
1760    fn detect_service_unknown_returns_none() {
1761        let headers = HeaderMap::new();
1762        let query = HashMap::new();
1763        let body = Bytes::new();
1764        assert!(detect_service(&headers, &query, &body).is_none());
1765    }
1766
1767    #[test]
1768    fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1769        // Real AWS API Gateway V2 SDK signs with `apigateway` per the
1770        // model's `endpointPrefix`, but Smithy-driven tooling (including
1771        // our conformance probe) sends `apigatewayv2`. Both routes resolve
1772        // to the same fakecloud service registry entry.
1773        assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1774    }
1775
1776    #[test]
1777    fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1778        // The bedrock-runtime credential scope shares path namespace with
1779        // the bedrock control plane (`POST /guardrail/{id}/version/{ver}/apply`
1780        // is implemented under BedrockService). Routing must resolve to
1781        // the bedrock service so the existing handlers run. See #1232.
1782        assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1783    }
1784
1785    #[test]
1786    fn normalize_service_name_passes_through_unaliased_services() {
1787        // Every service that isn't on the alias list must round-trip
1788        // unchanged — including the canonical bedrock name itself, so a
1789        // plain bedrock request takes the same code path it always has.
1790        assert_eq!(normalize_service_name("bedrock"), "bedrock");
1791        assert_eq!(normalize_service_name("s3"), "s3");
1792        assert_eq!(normalize_service_name("lambda"), "lambda");
1793        assert_eq!(normalize_service_name(""), "");
1794        assert_eq!(
1795            normalize_service_name("unknown-future-service"),
1796            "unknown-future-service"
1797        );
1798    }
1799
1800    #[test]
1801    fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1802        // SigV4 auth header carries `bedrock-runtime` in the credential
1803        // scope; dispatcher must route to the bedrock service handler so
1804        // `/guardrail/...` lands on `BedrockService` instead of falling
1805        // through to API Gateway.
1806        let mut headers = HeaderMap::new();
1807        headers.insert(
1808            "authorization",
1809            "AWS4-HMAC-SHA256 \
1810             Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1811             SignedHeaders=host, Signature=abc"
1812                .parse()
1813                .unwrap(),
1814        );
1815        let query = HashMap::new();
1816        let body = Bytes::new();
1817        let detected = detect_service(&headers, &query, &body).unwrap();
1818        assert_eq!(detected.service, "bedrock");
1819        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1820    }
1821
1822    #[test]
1823    fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1824        // Same alias normalization on the presigned-URL path: a request
1825        // signed with bedrock-runtime in the X-Amz-Credential query param
1826        // must still resolve to the bedrock service handler.
1827        let headers = HeaderMap::new();
1828        let mut query = HashMap::new();
1829        query.insert(
1830            "X-Amz-Credential".to_string(),
1831            "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1832        );
1833        let body = Bytes::new();
1834        let detected = detect_service(&headers, &query, &body).unwrap();
1835        assert_eq!(detected.service, "bedrock");
1836        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1837    }
1838
1839    #[test]
1840    fn normalize_host_strips_port_and_case() {
1841        assert_eq!(normalize_host("Rest.Example.com:4566"), "rest.example.com");
1842        assert_eq!(normalize_host("rest.example.com."), "rest.example.com");
1843        assert_eq!(normalize_host("rest.example.com"), "rest.example.com");
1844        assert_eq!(normalize_host("[::1]:4566"), "[::1]");
1845        assert_eq!(normalize_host("localhost"), "localhost");
1846    }
1847
1848    #[test]
1849    fn parse_routing_host_execute_api_hosts() {
1850        // LocalStack's execute-api host has no region label: it must not be
1851        // read as region `execute-api`.
1852        assert_eq!(
1853            parse_routing_host("abc123.execute-api.localhost.localstack.cloud:4566"),
1854            None
1855        );
1856        let h =
1857            parse_routing_host("abc123.execute-api.eu-west-1.localhost.localstack.cloud").unwrap();
1858        assert_eq!(h.service, "execute-api");
1859        assert_eq!(h.region, "eu-west-1");
1860        let h = parse_routing_host("abc123.execute-api.us-west-2.amazonaws.com").unwrap();
1861        assert_eq!(h.service, "execute-api");
1862        assert_eq!(h.region, "us-west-2");
1863        assert_eq!(h.bucket, None);
1864    }
1865
1866    #[test]
1867    fn detect_rpc_v2_cbor_requests() {
1868        let mut headers = HeaderMap::new();
1869        let path = "/service/GraniteServiceVersion20100801/operation/GetMetricData";
1870        // Without the protocol header the path alone is not enough.
1871        assert!(detect_rpc_v2_cbor(&headers, path).is_none());
1872        headers.insert("smithy-protocol", "rpc-v2-cbor".parse().unwrap());
1873        let d = detect_rpc_v2_cbor(&headers, path).unwrap();
1874        assert_eq!(d.service, "monitoring");
1875        assert_eq!(d.action, "GetMetricData");
1876        assert_eq!(d.protocol, AwsProtocol::RpcV2Cbor);
1877        assert!(detect_rpc_v2_cbor(&headers, "/service/Unknown/operation/Op").is_none());
1878        assert!(detect_rpc_v2_cbor(&headers, "/prod/items").is_none());
1879    }
1880
1881    #[test]
1882    fn parse_routing_host_localstack_basic() {
1883        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1884        assert_eq!(h.service, "sqs");
1885        assert_eq!(h.region, "us-east-1");
1886        assert!(h.bucket.is_none());
1887    }
1888
1889    #[test]
1890    fn parse_routing_host_localstack_with_port() {
1891        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1892        assert_eq!(h.service, "lambda");
1893        assert_eq!(h.region, "eu-west-1");
1894        assert!(h.bucket.is_none());
1895    }
1896
1897    #[test]
1898    fn parse_routing_host_case_insensitive() {
1899        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1900        assert_eq!(h.service, "sqs");
1901        assert_eq!(h.region, "us-east-1");
1902
1903        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1904        assert_eq!(h.service, "lambda");
1905        assert_eq!(h.region, "us-east-1");
1906    }
1907
1908    #[test]
1909    fn parse_routing_host_localstack_s3_virtual_hosted() {
1910        let h =
1911            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1912        assert_eq!(h.service, "s3");
1913        assert_eq!(h.region, "us-east-1");
1914        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1915    }
1916
1917    #[test]
1918    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1919        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1920        assert_eq!(h.service, "s3");
1921        assert_eq!(h.region, "us-east-1");
1922        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1923    }
1924
1925    #[test]
1926    fn parse_routing_host_aws_service_region() {
1927        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1928        assert_eq!(h.service, "sqs");
1929        assert_eq!(h.region, "us-east-1");
1930        assert!(h.bucket.is_none());
1931
1932        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1933        assert_eq!(h.service, "dynamodb");
1934        assert_eq!(h.region, "eu-west-2");
1935    }
1936
1937    #[test]
1938    fn parse_routing_host_aws_s3_path_style_modern() {
1939        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1940        assert_eq!(h.service, "s3");
1941        assert_eq!(h.region, "us-east-1");
1942        assert!(h.bucket.is_none());
1943    }
1944
1945    #[test]
1946    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1947        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1948        assert_eq!(h.service, "s3");
1949        assert_eq!(h.region, "us-east-1");
1950        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1951    }
1952
1953    #[test]
1954    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1955        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1956        assert_eq!(h.service, "s3");
1957        assert_eq!(h.region, "us-east-1");
1958        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1959    }
1960
1961    #[test]
1962    fn parse_routing_host_aws_s3_legacy_global() {
1963        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
1964        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
1965        let h = parse_routing_host("s3.amazonaws.com").unwrap();
1966        assert_eq!(h.service, "s3");
1967        assert_eq!(h.region, "us-east-1");
1968        assert!(h.bucket.is_none());
1969
1970        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1971        assert_eq!(h.service, "s3");
1972        assert_eq!(h.region, "us-east-1");
1973        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1974    }
1975
1976    #[test]
1977    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1978        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
1979        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
1980        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1981        assert_eq!(h.service, "s3");
1982        assert_eq!(h.region, "us-east-1");
1983        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1984    }
1985
1986    #[test]
1987    fn query_string_action_is_a_parameter_only_of_s3_and_api_gateway() {
1988        let detect = |host: Option<&str>, auth: Option<&str>, query: &[(&str, &str)]| {
1989            let mut headers = HeaderMap::new();
1990            if let Some(host) = host {
1991                headers.insert(http::header::HOST, host.parse().unwrap());
1992            }
1993            if let Some(auth) = auth {
1994                headers.insert(http::header::AUTHORIZATION, auth.parse().unwrap());
1995            }
1996            let params: HashMap<String, String> = query
1997                .iter()
1998                .map(|(k, v)| (k.to_string(), v.to_string()))
1999                .collect();
2000            let full =
2001                detect_service(&headers, &params, &Bytes::new()).map(|d| (d.service, d.action));
2002            let head =
2003                detect_service_headers_only(&headers, &params).map(|d| (d.service, d.action));
2004            assert_eq!(full, head, "{host:?} {query:?}");
2005            full
2006        };
2007        let s = |svc: &str, action: &str| Some((svc.to_string(), action.to_string()));
2008        // Nothing else names a service: the Action does (dispatch first
2009        // takes out a plain-host path naming an existing bucket).
2010        assert_eq!(
2011            detect(None, None, &[("Action", "ListQueues")]),
2012            s("sqs", "ListQueues")
2013        );
2014        // SES v1 is Query on SES's scope and host (v2 is restJson1 there).
2015        assert_eq!(
2016            detect(
2017                None,
2018                Some("AWS4-HMAC-SHA256 Credential=AKID/20261113/us-east-1/ses/aws4_request, SignedHeaders=host, Signature=x"),
2019                &[("Action", "ListIdentities")]
2020            ),
2021            s("ses", "ListIdentities")
2022        );
2023        assert_eq!(
2024            detect(
2025                Some("email.us-east-1.amazonaws.com"),
2026                None,
2027                &[("Action", "ListIdentities")]
2028            ),
2029            s("ses", "ListIdentities")
2030        );
2031        assert_eq!(
2032            detect(
2033                None,
2034                None,
2035                &[
2036                    ("Action", "ListIdentities"),
2037                    (
2038                        "X-Amz-Credential",
2039                        "AKID/20261113/us-east-1/ses/aws4_request"
2040                    ),
2041                ]
2042            ),
2043            s("ses", "ListIdentities")
2044        );
2045        // SigV2 Query authentication (SignatureVersion + Timestamp) is the
2046        // Action's service's; an S3 SigV2 presign (Expires) is S3's.
2047        for (action, service) in [("ListQueues", "sqs"), ("DescribeRegions", "ec2")] {
2048            assert_eq!(
2049                detect(
2050                    None,
2051                    None,
2052                    &[
2053                        ("Action", action),
2054                        ("AWSAccessKeyId", "AKID"),
2055                        ("Signature", "x"),
2056                        ("SignatureVersion", "2"),
2057                        ("SignatureMethod", "HmacSHA256"),
2058                        ("Timestamp", "2026-11-13T00:00:00Z"),
2059                    ]
2060                ),
2061                s(service, action)
2062            );
2063        }
2064        assert_eq!(
2065            detect(
2066                None,
2067                None,
2068                &[
2069                    ("Action", "ListQueues"),
2070                    ("AWSAccessKeyId", "AKID"),
2071                    ("Signature", "x"),
2072                    ("Expires", "1900000000"),
2073                ]
2074            ),
2075            s("s3", "")
2076        );
2077        // An S3 or API Gateway request keeps it: `Action` is a parameter.
2078        assert_eq!(
2079            detect(
2080                Some("pubb.s3.us-east-1.amazonaws.com"),
2081                None,
2082                &[("Action", "DescribeRegions")]
2083            ),
2084            s("s3", "")
2085        );
2086        assert_eq!(
2087            detect(
2088                Some("abc123.execute-api.us-east-1.amazonaws.com"),
2089                None,
2090                &[("Action", "Publish")]
2091            ),
2092            None,
2093            "an invocation: dispatch hands it to API Gateway"
2094        );
2095        assert_eq!(
2096            detect(
2097                None,
2098                None,
2099                &[
2100                    ("Action", "ListQueues"),
2101                    (
2102                        "X-Amz-Credential",
2103                        "AKID/20261113/us-east-1/s3/aws4_request"
2104                    ),
2105                ]
2106            ),
2107            s("s3", "")
2108        );
2109        assert_eq!(
2110            detect(
2111                None,
2112                Some("AWS4-HMAC-SHA256 Credential=AKID/20261113/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=x"),
2113                &[("Action", "ListTopics")]
2114            ),
2115            s("s3", "")
2116        );
2117        // A Query service's request is its Query call.
2118        assert_eq!(
2119            detect(
2120                Some("sqs.us-east-1.amazonaws.com"),
2121                None,
2122                &[("Action", "ListQueues")]
2123            ),
2124            s("sqs", "ListQueues")
2125        );
2126        assert_eq!(
2127            detect(
2128                None,
2129                None,
2130                &[
2131                    ("Action", "GetCallerIdentity"),
2132                    (
2133                        "X-Amz-Credential",
2134                        "AKID/20261113/us-east-1/sts/aws4_request"
2135                    ),
2136                ]
2137            ),
2138            s("sts", "GetCallerIdentity")
2139        );
2140    }
2141
2142    #[test]
2143    fn query_action_table_matches_the_models() {
2144        // Re-derives the table: rerun scripts/generate-query-action-services.py
2145        // after a model refresh.
2146        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../aws-models");
2147        let mut owners: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
2148            Default::default();
2149        for entry in std::fs::read_dir(&dir).unwrap() {
2150            let path = entry.unwrap().path();
2151            let text = std::fs::read_to_string(&path).unwrap();
2152            if !["awsQuery\"", "ec2Query\"", "awsQueryCompatible\""]
2153                .iter()
2154                .any(|p| text.contains(&format!("aws.protocols#{p}")))
2155            {
2156                continue;
2157            }
2158            let model: serde_json::Value = serde_json::from_str(&text).unwrap();
2159            let shapes = model["shapes"].as_object().unwrap();
2160            let service = shapes.values().find(|s| s["type"] == "service").unwrap();
2161            let traits = service["traits"].as_object().unwrap();
2162            if ![
2163                "aws.protocols#awsQuery",
2164                "aws.protocols#ec2Query",
2165                "aws.protocols#awsQueryCompatible",
2166            ]
2167            .iter()
2168            .any(|p| traits.contains_key(*p))
2169            {
2170                continue;
2171            }
2172            let signing = traits["aws.auth#sigv4"]["name"].as_str().unwrap();
2173            for (id, shape) in shapes {
2174                if shape["type"] == "operation" {
2175                    let op = id.split_once('#').unwrap().1;
2176                    owners
2177                        .entry(op.to_string())
2178                        .or_default()
2179                        .insert(signing.to_string());
2180                }
2181            }
2182        }
2183        let derived: Vec<(String, String)> = owners
2184            .into_iter()
2185            .filter(|(_, s)| s.len() == 1)
2186            .map(|(op, s)| (op, s.into_iter().next().unwrap()))
2187            .collect();
2188        let table: Vec<(String, String)> = query_actions::QUERY_ACTION_SERVICES
2189            .iter()
2190            .map(|(a, s)| (a.to_string(), s.to_string()))
2191            .collect();
2192        assert_eq!(
2193            table, derived,
2194            "rerun scripts/generate-query-action-services.py"
2195        );
2196    }
2197
2198    #[test]
2199    fn parse_routing_host_email_is_ses() {
2200        for host in [
2201            "email.us-east-1.amazonaws.com",
2202            "email.eu-west-1.localhost.localstack.cloud:4566",
2203        ] {
2204            assert_eq!(parse_routing_host(host).unwrap().service, "ses", "{host}");
2205        }
2206    }
2207
2208    #[test]
2209    fn parse_routing_host_global_endpoints() {
2210        for service in ["iam", "sts", "route53", "cloudfront"] {
2211            let h = parse_routing_host(&format!("{service}.amazonaws.com:443")).unwrap();
2212            assert_eq!(h.service, service);
2213            assert_eq!(h.region, "us-east-1");
2214            assert_eq!(h.bucket, None);
2215        }
2216        // Only those services have a region-less endpoint.
2217        assert!(parse_routing_host("lambda.amazonaws.com").is_none());
2218        assert!(parse_routing_host("route53.amazonaws.com.cn").is_none());
2219    }
2220
2221    #[test]
2222    fn parse_routing_host_aws_s3_dash_separated() {
2223        // Older dash-separated form still served by AWS.
2224        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
2225        assert_eq!(h.service, "s3");
2226        assert_eq!(h.region, "us-west-2");
2227        assert!(h.bucket.is_none());
2228
2229        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
2230        assert_eq!(h.service, "s3");
2231        assert_eq!(h.region, "us-west-2");
2232        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
2233    }
2234
2235    #[test]
2236    fn parse_routing_host_aws_s3_dualstack_and_fips() {
2237        for (host, bucket) in [
2238            (
2239                "my-bucket.s3.dualstack.us-east-1.amazonaws.com",
2240                Some("my-bucket"),
2241            ),
2242            ("a.b.s3.dualstack.eu-west-2.amazonaws.com", Some("a.b")),
2243            ("s3.dualstack.eu-west-2.amazonaws.com", None),
2244            (
2245                "my-bucket.s3-fips.us-gov-west-1.amazonaws.com",
2246                Some("my-bucket"),
2247            ),
2248            (
2249                "my-bucket.s3-fips.dualstack.us-east-1.amazonaws.com",
2250                Some("my-bucket"),
2251            ),
2252        ] {
2253            let h = parse_routing_host(host).unwrap();
2254            assert_eq!(h.service, "s3", "{host}");
2255            assert_eq!(h.bucket.as_deref(), bucket, "{host}");
2256        }
2257        let h = parse_routing_host("b.s3.dualstack.eu-west-2.amazonaws.com").unwrap();
2258        assert_eq!(h.region, "eu-west-2");
2259    }
2260
2261    #[test]
2262    fn parse_routing_host_aws_s3_website_endpoints() {
2263        // Dash form: the region is what follows `s3-website-`, not
2264        // `website-<region>` as the generic `s3-<region>` rule would read it.
2265        let h = parse_routing_host("site.s3-website-us-west-2.amazonaws.com").unwrap();
2266        assert_eq!(h.service, "s3");
2267        assert_eq!(h.region, "us-west-2");
2268        assert_eq!(h.bucket.as_deref(), Some("site"));
2269
2270        // Dot form (every region launched since 2014).
2271        let h = parse_routing_host("my.site.s3-website.eu-central-1.amazonaws.com").unwrap();
2272        assert_eq!(h.service, "s3");
2273        assert_eq!(h.region, "eu-central-1");
2274        assert_eq!(h.bucket.as_deref(), Some("my.site"));
2275    }
2276
2277    #[test]
2278    fn parse_routing_host_other_partition_suffixes() {
2279        let h = parse_routing_host("my-bucket.s3.cn-north-1.amazonaws.com.cn").unwrap();
2280        assert_eq!(h.service, "s3");
2281        assert_eq!(h.region, "cn-north-1");
2282        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
2283
2284        let h = parse_routing_host("sqs.cn-northwest-1.amazonaws.com.cn").unwrap();
2285        assert_eq!(h.service, "sqs");
2286        assert_eq!(h.region, "cn-northwest-1");
2287
2288        let h = parse_routing_host("b.s3.us-iso-east-1.c2s.ic.gov").unwrap();
2289        assert_eq!(h.region, "us-iso-east-1");
2290        assert_eq!(h.bucket.as_deref(), Some("b"));
2291
2292        assert!(parse_routing_host("amazonaws.com.cn").is_none());
2293        assert!(parse_routing_host(".amazonaws.com.cn").is_none());
2294    }
2295
2296    #[test]
2297    fn parse_routing_host_rejects_plain_localhost() {
2298        assert!(parse_routing_host("localhost:4566").is_none());
2299        assert!(parse_routing_host("127.0.0.1:4566").is_none());
2300    }
2301
2302    #[test]
2303    fn parse_routing_host_rejects_unknown_suffix() {
2304        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
2305        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
2306    }
2307
2308    #[test]
2309    fn parse_routing_host_empty_and_malformed_rejected() {
2310        assert!(parse_routing_host("").is_none());
2311        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
2312        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
2313        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
2314        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
2315        assert!(parse_routing_host(".amazonaws.com").is_none());
2316        assert!(parse_routing_host("amazonaws.com").is_none());
2317    }
2318
2319    #[test]
2320    fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
2321        // A single-label "s3-accesspoint" host has < 2 labels, so the
2322        // virtual-hosted `len() - 2` slice would underflow and panic without
2323        // the length guard. It must be rejected, not crash the router.
2324        assert!(parse_routing_host("s3-accesspoint").is_none());
2325    }
2326
2327    #[test]
2328    fn detect_service_via_host_for_rest_service() {
2329        let mut headers = HeaderMap::new();
2330        headers.insert(
2331            "host",
2332            "s3.us-east-1.localhost.localstack.cloud:4566"
2333                .parse()
2334                .unwrap(),
2335        );
2336        let query = HashMap::new();
2337        let body = Bytes::new();
2338        let detected = detect_service(&headers, &query, &body).unwrap();
2339        assert_eq!(detected.service, "s3");
2340        assert_eq!(detected.protocol, AwsProtocol::Rest);
2341    }
2342
2343    #[test]
2344    fn detect_service_via_host_for_rest_json_service() {
2345        let mut headers = HeaderMap::new();
2346        headers.insert(
2347            "host",
2348            "lambda.us-east-1.localhost.localstack.cloud:4566"
2349                .parse()
2350                .unwrap(),
2351        );
2352        let query = HashMap::new();
2353        let body = Bytes::new();
2354        let detected = detect_service(&headers, &query, &body).unwrap();
2355        assert_eq!(detected.service, "lambda");
2356        assert_eq!(detected.protocol, AwsProtocol::RestJson);
2357    }
2358
2359    #[test]
2360    fn detect_service_via_host_plus_query_action() {
2361        let mut headers = HeaderMap::new();
2362        headers.insert(
2363            "host",
2364            "sqs.us-east-1.localhost.localstack.cloud:4566"
2365                .parse()
2366                .unwrap(),
2367        );
2368        let mut query = HashMap::new();
2369        query.insert("Action".to_string(), "ListQueues".to_string());
2370        let body = Bytes::new();
2371        let detected = detect_service(&headers, &query, &body).unwrap();
2372        assert_eq!(detected.service, "sqs");
2373        assert_eq!(detected.action, "ListQueues");
2374        assert_eq!(detected.protocol, AwsProtocol::Query);
2375    }
2376
2377    #[test]
2378    fn detect_service_sigv4_wins_over_host() {
2379        let mut headers = HeaderMap::new();
2380        headers.insert(
2381            "authorization",
2382            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
2383             SignedHeaders=host, Signature=abc"
2384                .parse()
2385                .unwrap(),
2386        );
2387        headers.insert(
2388            "host",
2389            "lambda.us-east-1.localhost.localstack.cloud:4566"
2390                .parse()
2391                .unwrap(),
2392        );
2393        let query = HashMap::new();
2394        let body = Bytes::new();
2395        let detected = detect_service(&headers, &query, &body).unwrap();
2396        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
2397        assert_eq!(detected.service, "s3");
2398        assert_eq!(detected.protocol, AwsProtocol::Rest);
2399    }
2400
2401    #[test]
2402    fn detect_service_host_for_virtual_hosted_s3() {
2403        let mut headers = HeaderMap::new();
2404        headers.insert(
2405            "host",
2406            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
2407                .parse()
2408                .unwrap(),
2409        );
2410        let query = HashMap::new();
2411        let body = Bytes::new();
2412        let detected = detect_service(&headers, &query, &body).unwrap();
2413        assert_eq!(detected.service, "s3");
2414        assert_eq!(detected.protocol, AwsProtocol::Rest);
2415    }
2416}