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