Skip to main content

fakecloud_core/
protocol.rs

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