Skip to main content

fakecloud_core/
protocol.rs

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