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    "lambda",
28    "ses",
29    "apigateway",
30    "bedrock",
31    "bedrock-agent",
32    "bedrock-agent-runtime",
33    "scheduler",
34    "batch",
35];
36
37/// Detected service name and action from an incoming HTTP request.
38#[derive(Debug, Clone)]
39pub struct DetectedRequest {
40    pub service: String,
41    pub action: String,
42    pub protocol: AwsProtocol,
43}
44
45/// Header-only service detection. Skips the form-encoded body sniff so
46/// the dispatch path can decide whether to stream or buffer the body
47/// without first reading it. Returns `None` when only a body sniff
48/// would succeed; the caller must then fall back to [`detect_service`]
49/// after buffering. Used to opt streaming routes (S3 PutObject /
50/// UploadPart, ECR OCI v2 blob upload) out of the global body cap.
51pub fn detect_service_headers_only(
52    headers: &HeaderMap,
53    query_params: &HashMap<String, String>,
54) -> Option<DetectedRequest> {
55    // Mirrors `detect_service` minus step 3 (form-body sniff).
56    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
57        return parse_amz_target(target);
58    }
59    if let Some(action) = query_params.get("Action") {
60        let service = extract_service_from_auth(headers)
61            .or_else(|| infer_service_from_action(action))
62            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
63        if let Some(service) = service {
64            return Some(DetectedRequest {
65                service,
66                action: action.clone(),
67                protocol: AwsProtocol::Query,
68            });
69        }
70    }
71    if let Some(service) = extract_service_from_auth(headers) {
72        if let Some(protocol) = rest_protocol_for(&service) {
73            return Some(DetectedRequest {
74                service,
75                action: String::new(),
76                protocol,
77            });
78        }
79    }
80    if let Some(credential) = query_params.get("X-Amz-Credential") {
81        let parts: Vec<&str> = credential.split('/').collect();
82        if parts.len() >= 4 {
83            let service = normalize_service_name(parts[3]).to_string();
84            if let Some(protocol) = rest_protocol_for(&service) {
85                return Some(DetectedRequest {
86                    service,
87                    action: String::new(),
88                    protocol,
89                });
90            }
91        }
92    }
93    if query_params.contains_key("AWSAccessKeyId")
94        && query_params.contains_key("Signature")
95        && query_params.contains_key("Expires")
96    {
97        return Some(DetectedRequest {
98            service: "s3".to_string(),
99            action: String::new(),
100            protocol: AwsProtocol::Rest,
101        });
102    }
103    if let Some(host_info) = parse_routing_host_from_headers(headers) {
104        if let Some(protocol) = rest_protocol_for(&host_info.service) {
105            return Some(DetectedRequest {
106                service: host_info.service,
107                action: String::new(),
108                protocol,
109            });
110        }
111    }
112    None
113}
114
115/// Detect the target service and action from HTTP request components.
116pub fn detect_service(
117    headers: &HeaderMap,
118    query_params: &HashMap<String, String>,
119    body: &Bytes,
120) -> Option<DetectedRequest> {
121    // 1. Check X-Amz-Target header (JSON protocol)
122    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
123        return parse_amz_target(target);
124    }
125
126    // 2. Check for Query protocol (Action parameter in query string or form body)
127    if let Some(action) = query_params.get("Action") {
128        let service = extract_service_from_auth(headers)
129            .or_else(|| infer_service_from_action(action))
130            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
131        if let Some(service) = service {
132            return Some(DetectedRequest {
133                service,
134                action: action.clone(),
135                protocol: AwsProtocol::Query,
136            });
137        }
138    }
139
140    // 3. Try form-encoded body
141    {
142        let form_params = decode_form_urlencoded(body);
143
144        if let Some(action) = form_params.get("Action") {
145            let service = extract_service_from_auth(headers)
146                .or_else(|| infer_service_from_action(action))
147                .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
148            if let Some(service) = service {
149                return Some(DetectedRequest {
150                    service,
151                    action: action.clone(),
152                    protocol: AwsProtocol::Query,
153                });
154            }
155        }
156    }
157
158    // 4. Fallback: check auth header for REST-style services (S3, Lambda, SES, etc.)
159    if let Some(service) = extract_service_from_auth(headers) {
160        if let Some(protocol) = rest_protocol_for(&service) {
161            return Some(DetectedRequest {
162                service,
163                action: String::new(), // REST services determine action from method+path
164                protocol,
165            });
166        }
167    }
168
169    // 5. Check query params for presigned URL auth (X-Amz-Credential for SigV4)
170    if let Some(credential) = query_params.get("X-Amz-Credential") {
171        // Format: AKID/date/region/service/aws4_request
172        let parts: Vec<&str> = credential.split('/').collect();
173        if parts.len() >= 4 {
174            let service = normalize_service_name(parts[3]).to_string();
175            if let Some(protocol) = rest_protocol_for(&service) {
176                return Some(DetectedRequest {
177                    service,
178                    action: String::new(),
179                    protocol,
180                });
181            }
182        }
183    }
184
185    // 6. Check for SigV2-style presigned URL (AWSAccessKeyId + Signature + Expires)
186    //    Only match when all three SigV2 presigned-URL parameters are present so
187    //    we don't accidentally claim non-S3 requests.
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
199    // 7. Fallback: unsigned REST-style request carrying a LocalStack-shaped
200    //    Host header. Lets fixtures and curl-style probes reach the right
201    //    service without SigV4; signed requests were already handled in step 4.
202    if let Some(host_info) = parse_routing_host_from_headers(headers) {
203        if let Some(protocol) = rest_protocol_for(&host_info.service) {
204            return Some(DetectedRequest {
205                service: host_info.service,
206                action: String::new(),
207                protocol,
208            });
209        }
210    }
211
212    None
213}
214
215/// Service + region (and optional bucket) decoded from a `Host` header.
216/// Covers both the LocalStack hostname convention
217/// (`<service>.<region>.localhost.localstack.cloud[:port]`,
218/// `<bucket>.s3.<region>.localhost.localstack.cloud[:port]`) and real AWS
219/// service hostnames (`<service>.<region>.amazonaws.com`, S3 path-style
220/// and virtual-hosted-style including the legacy no-region
221/// `s3.amazonaws.com` / `<bucket>.s3.amazonaws.com` forms and the older
222/// dash-separated `s3-<region>.amazonaws.com` form).
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct RoutingHost {
225    pub service: String,
226    pub region: String,
227    /// Set only for virtual-hosted-style S3 hostnames.
228    pub bucket: Option<String>,
229}
230
231const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
232const AWS_SUFFIX: &str = ".amazonaws.com";
233
234/// Parse a `Host` header value for a LocalStack- or AWS-shaped hostname.
235/// Returns `None` for anything that doesn't match — callers fall through
236/// to their existing detection path.
237pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
238    let hostname = host.split(':').next()?;
239    if hostname.is_empty() {
240        return None;
241    }
242    let hostname = hostname.to_ascii_lowercase();
243    if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
244        return parse_localstack_prefix(prefix);
245    }
246    if hostname == "amazonaws.com" {
247        return None;
248    }
249    if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
250        return parse_aws_prefix(prefix);
251    }
252    None
253}
254
255/// Pull the `Host` header and parse it with [`parse_routing_host`].
256pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
257    let host = headers.get("host")?.to_str().ok()?;
258    parse_routing_host(host)
259}
260
261fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
262    if prefix.is_empty() {
263        return None;
264    }
265    let labels: Vec<&str> = prefix.split('.').collect();
266    if labels.iter().any(|l| l.is_empty()) {
267        return None;
268    }
269    match labels.len() {
270        2 => Some(RoutingHost {
271            service: labels[0].to_string(),
272            region: labels[1].to_string(),
273            bucket: None,
274        }),
275        n if n >= 3 && labels[n - 2] == "s3" => {
276            let bucket = labels[..n - 2].join(".");
277            Some(RoutingHost {
278                service: "s3".to_string(),
279                region: labels[n - 1].to_string(),
280                bucket: Some(bucket),
281            })
282        }
283        n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
284            let bucket = labels[..n - 2].join(".");
285            Some(RoutingHost {
286                service: "s3".to_string(),
287                region: labels[n - 1].to_string(),
288                bucket: Some(bucket),
289            })
290        }
291        n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
292            service: "s3".to_string(),
293            region: labels[n - 1].to_string(),
294            bucket: None,
295        }),
296        _ => None,
297    }
298}
299
300/// Parse the prefix before `.amazonaws.com`.
301///
302/// Handles every variant AWS has shipped for the common REST/Query services:
303///
304/// - `<service>.<region>` — modern regional endpoint (most services).
305/// - `s3.<region>` — modern path-style S3.
306/// - `<bucket>.s3.<region>` — modern virtual-hosted S3 (bucket may contain dots).
307/// - `s3` — legacy S3 global endpoint (implicitly `us-east-1`).
308/// - `<bucket>.s3` — legacy virtual-hosted S3 (implicitly `us-east-1`).
309/// - `s3-<region>` — older dash-separated path-style S3.
310/// - `<bucket>.s3-<region>` — older dash-separated virtual-hosted S3.
311fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
312    if prefix.is_empty() {
313        return None;
314    }
315    let labels: Vec<&str> = prefix.split('.').collect();
316    if labels.iter().any(|l| l.is_empty()) {
317        return None;
318    }
319    let last = *labels.last()?;
320
321    // `s3-<region>` as the last label: dash-separated S3. Bucket, if any,
322    // is whatever precedes it.
323    if let Some(region) = last.strip_prefix("s3-") {
324        if !region.is_empty() {
325            let bucket = if labels.len() >= 2 {
326                Some(labels[..labels.len() - 1].join("."))
327            } else {
328                None
329            };
330            return Some(RoutingHost {
331                service: "s3".to_string(),
332                region: region.to_string(),
333                bucket,
334            });
335        }
336    }
337
338    // Legacy global S3: last label is `s3`, no region present. `s3` on its
339    // own is the path-style global endpoint; anything preceding it is the
340    // bucket (including dotted names like `a.b.s3.amazonaws.com`).
341    if last == "s3" {
342        if labels.len() == 1 {
343            return Some(RoutingHost {
344                service: "s3".to_string(),
345                region: "us-east-1".to_string(),
346                bucket: None,
347            });
348        }
349        return Some(RoutingHost {
350            service: "s3".to_string(),
351            region: "us-east-1".to_string(),
352            bucket: Some(labels[..labels.len() - 1].join(".")),
353        });
354    }
355
356    // `s3-accesspoint.<region>` — path-style access point endpoint.
357    // `{alias}-{account-id}.s3-accesspoint.<region>` — virtual-hosted access point.
358    if last == "s3-accesspoint" {
359        if labels.len() == 2 {
360            return Some(RoutingHost {
361                service: "s3".to_string(),
362                region: labels[0].to_string(),
363                bucket: None,
364            });
365        }
366        // Virtual-hosted form needs at least {alias}.{region}.s3-accesspoint, i.e.
367        // 3+ labels. A bare "s3-accesspoint" host (1 label) must not reach the
368        // `len() - 2` slice, which would underflow and panic.
369        if labels.len() >= 3 {
370            let bucket = labels[..labels.len() - 2].join(".");
371            return Some(RoutingHost {
372                service: "s3".to_string(),
373                region: labels[labels.len() - 1].to_string(),
374                bucket: Some(bucket),
375            });
376        }
377    }
378
379    // `s3-control.<region>` or `{account-id}.s3-control.<region>` — S3
380    // Control endpoint (access point management).
381    if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
382        return Some(RoutingHost {
383            service: "s3".to_string(),
384            region: last.to_string(),
385            bucket: None,
386        });
387    }
388
389    match labels.len() {
390        // `<service>.<region>` — the common case. Covers `s3.<region>`
391        // path-style S3 too, since the service label falls through here.
392        2 => Some(RoutingHost {
393            service: labels[0].to_string(),
394            region: labels[1].to_string(),
395            bucket: None,
396        }),
397        // `<bucket>.s3.<region>` — modern virtual-hosted S3.
398        n if n >= 3 && labels[n - 2] == "s3" => {
399            let bucket = labels[..n - 2].join(".");
400            Some(RoutingHost {
401                service: "s3".to_string(),
402                region: labels[n - 1].to_string(),
403                bucket: Some(bucket),
404            })
405        }
406        _ => None,
407    }
408}
409
410/// Parse `X-Amz-Target: AWSEvents.PutEvents` -> service=events, action=PutEvents
411/// Parse `X-Amz-Target: AmazonSSM.GetParameter` -> service=ssm, action=GetParameter
412fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
413    let (prefix, action) = target.rsplit_once('.')?;
414
415    let service = match prefix {
416        "AWSEvents" => "events",
417        "AmazonSSM" => "ssm",
418        "AmazonSQS" => "sqs",
419        "AmazonSNS" => "sns",
420        "DynamoDB_20120810" => "dynamodb",
421        "DynamoDBStreams_20120810" => "dynamodbstreams",
422        "Logs_20140328" => "logs",
423        s if s.starts_with("secretsmanager") => "secretsmanager",
424        s if s.starts_with("TrentService") => "kms",
425        s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
426        s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
427        s if s.starts_with("Kinesis_20131202") => "kinesis",
428        s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
429        s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
430        s if s.starts_with("AWSStepFunctions") => "states",
431        s if s.starts_with("AWSOrganizationsV") => "organizations",
432        "CertificateManager" => "acm",
433        "AnyScaleFrontendService" => "application-autoscaling",
434        // Match the WAFv2 target version exactly so legacy WAF Classic
435        // (`AWSWAF_*` without the `_20190729` suffix) doesn't get routed here.
436        "AWSWAF_20190729" => "wafv2",
437        "AmazonAthena" => "athena",
438        s if s.starts_with("Firehose_") => "firehose",
439        "AWSGlue" => "glue",
440        _ => return None,
441    };
442
443    Some(DetectedRequest {
444        service: service.to_string(),
445        action: action.to_string(),
446        protocol: AwsProtocol::Json,
447    })
448}
449
450/// Returns the REST protocol variant for a service, or None if not a REST service.
451fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
452    if REST_XML_SERVICES.contains(&service) {
453        Some(AwsProtocol::Rest)
454    } else if REST_JSON_SERVICES.contains(&service) {
455        Some(AwsProtocol::RestJson)
456    } else {
457        None
458    }
459}
460
461/// Infer service from the action name when no SigV4 auth is present.
462/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
463/// do not require authentication and won't have an Authorization header.
464fn infer_service_from_action(action: &str) -> Option<String> {
465    match action {
466        "AssumeRole"
467        | "AssumeRoleWithSAML"
468        | "AssumeRoleWithWebIdentity"
469        | "GetCallerIdentity"
470        | "GetSessionToken"
471        | "GetFederationToken"
472        | "GetAccessKeyInfo"
473        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
474        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
475        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
476        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
477        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
478        // SES v1 (Query protocol)
479        "VerifyEmailIdentity"
480        | "VerifyDomainIdentity"
481        | "VerifyDomainDkim"
482        | "ListIdentities"
483        | "GetIdentityVerificationAttributes"
484        | "GetIdentityDkimAttributes"
485        | "DeleteIdentity"
486        | "SetIdentityDkimEnabled"
487        | "SetIdentityNotificationTopic"
488        | "SetIdentityFeedbackForwardingEnabled"
489        | "GetIdentityNotificationAttributes"
490        | "GetIdentityMailFromDomainAttributes"
491        | "SetIdentityMailFromDomain"
492        | "SendEmail"
493        | "SendRawEmail"
494        | "SendTemplatedEmail"
495        | "SendBulkTemplatedEmail"
496        | "CreateTemplate"
497        | "GetTemplate"
498        | "ListTemplates"
499        | "DeleteTemplate"
500        | "UpdateTemplate"
501        | "CreateConfigurationSet"
502        | "DeleteConfigurationSet"
503        | "DescribeConfigurationSet"
504        | "ListConfigurationSets"
505        | "CreateConfigurationSetEventDestination"
506        | "UpdateConfigurationSetEventDestination"
507        | "DeleteConfigurationSetEventDestination"
508        | "GetSendQuota"
509        | "GetSendStatistics"
510        | "GetAccountSendingEnabled"
511        | "CreateReceiptRuleSet"
512        | "DeleteReceiptRuleSet"
513        | "DescribeReceiptRuleSet"
514        | "ListReceiptRuleSets"
515        | "CloneReceiptRuleSet"
516        | "SetActiveReceiptRuleSet"
517        | "ReorderReceiptRuleSet"
518        | "CreateReceiptRule"
519        | "DeleteReceiptRule"
520        | "DescribeReceiptRule"
521        | "UpdateReceiptRule"
522        | "CreateReceiptFilter"
523        | "DeleteReceiptFilter"
524        | "ListReceiptFilters" => Some("ses".to_string()),
525        _ => None,
526    }
527}
528
529/// Extract service name from the SigV4 Authorization header credential scope.
530fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
531    let auth = headers.get("authorization")?.to_str().ok()?;
532    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
533    Some(normalize_service_name(&info.service).to_string())
534}
535
536/// Map AWS service-name aliases that share path namespace and handlers
537/// to the canonical form used by fakecloud's service registry.
538///
539/// AWS uses `bedrock-runtime` in the SigV4 credential scope of runtime
540/// API calls (`InvokeModel`, `ApplyGuardrail`, etc.) but the REST paths
541/// (e.g. `POST /guardrail/{id}/version/{ver}/apply`) live under the same
542/// `BedrockService` handler that owns the control-plane `bedrock` paths.
543/// Without normalization, `detect_service` returns `None` for
544/// `bedrock-runtime` (not in `REST_JSON_SERVICES`), the central
545/// dispatcher falls back to API Gateway, and `/guardrail/...` 404s with
546/// `NotFoundException: Stage not found: guardrail`. See issue #1232.
547fn normalize_service_name(service: &str) -> &str {
548    match service {
549        "bedrock-runtime" => "bedrock",
550        // Real AWS API Gateway V2 SDK signs with `apigateway` as the SigV4
551        // service (per the model's `aws.api#service.endpointPrefix`), but
552        // tools driven by the Smithy service shape name (including our own
553        // conformance probe) may send `apigatewayv2`. Both refer to the
554        // same fakecloud service registry entry — the v2 handler is path-
555        // routed under `/v2/...` and the v1 handler under `/restapis/...`,
556        // both reachable behind the `apigateway` SigV4 service.
557        "apigatewayv2" => "apigateway",
558        other => other,
559    }
560}
561
562/// Parse form-encoded body into key-value pairs.
563pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
564    decode_form_urlencoded(body)
565}
566
567fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
568    let s = std::str::from_utf8(input).unwrap_or("");
569    let mut result = HashMap::new();
570    for pair in s.split('&') {
571        if pair.is_empty() {
572            continue;
573        }
574        let (key, value) = match pair.find('=') {
575            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
576            None => (pair, ""),
577        };
578        result.insert(url_decode(key), url_decode(value));
579    }
580    result
581}
582
583fn url_decode(input: &str) -> String {
584    let mut result = String::with_capacity(input.len());
585    let mut bytes = input.bytes();
586    while let Some(b) = bytes.next() {
587        match b {
588            b'+' => result.push(' '),
589            b'%' => {
590                let high = bytes.next().and_then(from_hex);
591                let low = bytes.next().and_then(from_hex);
592                if let (Some(h), Some(l)) = (high, low) {
593                    result.push((h << 4 | l) as char);
594                }
595            }
596            _ => result.push(b as char),
597        }
598    }
599    result
600}
601
602fn from_hex(b: u8) -> Option<u8> {
603    match b {
604        b'0'..=b'9' => Some(b - b'0'),
605        b'a'..=b'f' => Some(b - b'a' + 10),
606        b'A'..=b'F' => Some(b - b'A' + 10),
607        _ => None,
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614
615    #[test]
616    fn parse_amz_target_events() {
617        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
618        assert_eq!(result.service, "events");
619        assert_eq!(result.action, "PutEvents");
620        assert_eq!(result.protocol, AwsProtocol::Json);
621    }
622
623    #[test]
624    fn parse_amz_target_ssm() {
625        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
626        assert_eq!(result.service, "ssm");
627        assert_eq!(result.action, "GetParameter");
628    }
629
630    #[test]
631    fn parse_amz_target_kinesis() {
632        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
633        assert_eq!(result.service, "kinesis");
634        assert_eq!(result.action, "ListStreams");
635        assert_eq!(result.protocol, AwsProtocol::Json);
636    }
637
638    #[test]
639    fn parse_query_body_basic() {
640        let body = Bytes::from(
641            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
642        );
643        let params = parse_query_body(&body);
644        assert_eq!(params.get("Action").unwrap(), "SendMessage");
645        assert_eq!(params.get("MessageBody").unwrap(), "hello");
646    }
647
648    #[test]
649    fn parse_query_body_empty_returns_empty_map() {
650        let body = Bytes::from("");
651        let params = parse_query_body(&body);
652        assert!(params.is_empty());
653    }
654
655    #[test]
656    fn parse_query_body_duplicate_keys_last_wins() {
657        let body = Bytes::from("key=a&key=b");
658        let params = parse_query_body(&body);
659        assert_eq!(params.get("key").unwrap(), "b");
660    }
661
662    #[test]
663    fn parse_query_body_single_key() {
664        let body = Bytes::from("key=value");
665        let params = parse_query_body(&body);
666        assert_eq!(params.get("key").unwrap(), "value");
667    }
668
669    #[test]
670    fn parse_amz_target_ecs() {
671        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
672        assert_eq!(result.service, "ecs");
673        assert_eq!(result.action, "ListClusters");
674        assert_eq!(result.protocol, AwsProtocol::Json);
675    }
676
677    #[test]
678    fn parse_amz_target_invalid_returns_none() {
679        assert!(parse_amz_target("NoDotHere").is_none());
680        assert!(parse_amz_target("").is_none());
681    }
682
683    #[test]
684    fn parse_amz_target_various_prefixes() {
685        assert_eq!(
686            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
687            "sqs"
688        );
689        assert_eq!(
690            parse_amz_target("AmazonSNS.Publish").unwrap().service,
691            "sns"
692        );
693        assert_eq!(
694            parse_amz_target("DynamoDB_20120810.GetItem")
695                .unwrap()
696                .service,
697            "dynamodb"
698        );
699        assert_eq!(
700            parse_amz_target("Logs_20140328.PutLogEvents")
701                .unwrap()
702                .service,
703            "logs"
704        );
705        assert_eq!(
706            parse_amz_target("secretsmanager.GetSecretValue")
707                .unwrap()
708                .service,
709            "secretsmanager"
710        );
711        assert_eq!(
712            parse_amz_target("TrentService.Encrypt").unwrap().service,
713            "kms"
714        );
715        assert_eq!(
716            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
717                .unwrap()
718                .service,
719            "cognito-idp"
720        );
721        assert_eq!(
722            parse_amz_target("AWSStepFunctions.StartExecution")
723                .unwrap()
724                .service,
725            "states"
726        );
727        assert_eq!(
728            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
729                .unwrap()
730                .service,
731            "organizations"
732        );
733        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
734    }
735
736    #[test]
737    fn infer_service_from_action_maps_sts() {
738        assert_eq!(
739            infer_service_from_action("AssumeRole").as_deref(),
740            Some("sts")
741        );
742        assert_eq!(
743            infer_service_from_action("GetCallerIdentity").as_deref(),
744            Some("sts")
745        );
746    }
747
748    #[test]
749    fn infer_service_from_action_maps_iam() {
750        assert_eq!(
751            infer_service_from_action("CreateUser").as_deref(),
752            Some("iam")
753        );
754        assert_eq!(
755            infer_service_from_action("ListRoles").as_deref(),
756            Some("iam")
757        );
758    }
759
760    #[test]
761    fn infer_service_from_action_maps_ses() {
762        assert_eq!(
763            infer_service_from_action("SendEmail").as_deref(),
764            Some("ses")
765        );
766        assert_eq!(
767            infer_service_from_action("ListIdentities").as_deref(),
768            Some("ses")
769        );
770    }
771
772    #[test]
773    fn infer_service_from_action_unknown_returns_none() {
774        assert!(infer_service_from_action("NotARealAction").is_none());
775    }
776
777    #[test]
778    fn rest_protocol_for_returns_none_for_non_rest_service() {
779        assert!(rest_protocol_for("sqs").is_none());
780    }
781
782    #[test]
783    fn url_decode_handles_percent_and_plus() {
784        assert_eq!(url_decode("hello+world"), "hello world");
785        assert_eq!(url_decode("hello%20world"), "hello world");
786        assert_eq!(url_decode("100%25"), "100%");
787    }
788
789    #[test]
790    fn url_decode_ignores_malformed_percent() {
791        assert_eq!(url_decode("%ZZ"), "");
792    }
793
794    #[test]
795    fn from_hex_valid_digits() {
796        assert_eq!(from_hex(b'0'), Some(0));
797        assert_eq!(from_hex(b'9'), Some(9));
798        assert_eq!(from_hex(b'a'), Some(10));
799        assert_eq!(from_hex(b'F'), Some(15));
800    }
801
802    #[test]
803    fn from_hex_invalid_returns_none() {
804        assert!(from_hex(b'g').is_none());
805        assert!(from_hex(b' ').is_none());
806    }
807
808    #[test]
809    fn detect_service_via_amz_target() {
810        let mut headers = HeaderMap::new();
811        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
812        let query = HashMap::new();
813        let body = Bytes::new();
814        let detected = detect_service(&headers, &query, &body).unwrap();
815        assert_eq!(detected.service, "ssm");
816        assert_eq!(detected.action, "GetParameter");
817    }
818
819    #[test]
820    fn detect_service_via_query_action_with_inferred_service() {
821        let headers = HeaderMap::new();
822        let mut query = HashMap::new();
823        query.insert("Action".to_string(), "AssumeRole".to_string());
824        let body = Bytes::new();
825        let detected = detect_service(&headers, &query, &body).unwrap();
826        assert_eq!(detected.service, "sts");
827        assert_eq!(detected.action, "AssumeRole");
828        assert_eq!(detected.protocol, AwsProtocol::Query);
829    }
830
831    #[test]
832    fn detect_service_via_form_body() {
833        let headers = HeaderMap::new();
834        let query = HashMap::new();
835        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
836        let detected = detect_service(&headers, &query, &body).unwrap();
837        assert_eq!(detected.service, "ses");
838        assert_eq!(detected.action, "SendEmail");
839    }
840
841    #[test]
842    fn detect_service_via_sigv2_presigned() {
843        let headers = HeaderMap::new();
844        let mut query = HashMap::new();
845        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
846        query.insert("Signature".to_string(), "sig".to_string());
847        query.insert("Expires".to_string(), "1234567890".to_string());
848        let body = Bytes::new();
849        let detected = detect_service(&headers, &query, &body).unwrap();
850        assert_eq!(detected.service, "s3");
851        assert_eq!(detected.protocol, AwsProtocol::Rest);
852    }
853
854    #[test]
855    fn detect_service_via_sigv4_presigned_credential() {
856        let headers = HeaderMap::new();
857        let mut query = HashMap::new();
858        query.insert(
859            "X-Amz-Credential".to_string(),
860            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
861        );
862        let body = Bytes::new();
863        let detected = detect_service(&headers, &query, &body).unwrap();
864        assert_eq!(detected.service, "s3");
865        assert_eq!(detected.protocol, AwsProtocol::Rest);
866    }
867
868    #[test]
869    fn detect_service_unknown_returns_none() {
870        let headers = HeaderMap::new();
871        let query = HashMap::new();
872        let body = Bytes::new();
873        assert!(detect_service(&headers, &query, &body).is_none());
874    }
875
876    #[test]
877    fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
878        // Real AWS API Gateway V2 SDK signs with `apigateway` per the
879        // model's `endpointPrefix`, but Smithy-driven tooling (including
880        // our conformance probe) sends `apigatewayv2`. Both routes resolve
881        // to the same fakecloud service registry entry.
882        assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
883    }
884
885    #[test]
886    fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
887        // The bedrock-runtime credential scope shares path namespace with
888        // the bedrock control plane (`POST /guardrail/{id}/version/{ver}/apply`
889        // is implemented under BedrockService). Routing must resolve to
890        // the bedrock service so the existing handlers run. See #1232.
891        assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
892    }
893
894    #[test]
895    fn normalize_service_name_passes_through_unaliased_services() {
896        // Every service that isn't on the alias list must round-trip
897        // unchanged — including the canonical bedrock name itself, so a
898        // plain bedrock request takes the same code path it always has.
899        assert_eq!(normalize_service_name("bedrock"), "bedrock");
900        assert_eq!(normalize_service_name("s3"), "s3");
901        assert_eq!(normalize_service_name("lambda"), "lambda");
902        assert_eq!(normalize_service_name(""), "");
903        assert_eq!(
904            normalize_service_name("unknown-future-service"),
905            "unknown-future-service"
906        );
907    }
908
909    #[test]
910    fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
911        // SigV4 auth header carries `bedrock-runtime` in the credential
912        // scope; dispatcher must route to the bedrock service handler so
913        // `/guardrail/...` lands on `BedrockService` instead of falling
914        // through to API Gateway.
915        let mut headers = HeaderMap::new();
916        headers.insert(
917            "authorization",
918            "AWS4-HMAC-SHA256 \
919             Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
920             SignedHeaders=host, Signature=abc"
921                .parse()
922                .unwrap(),
923        );
924        let query = HashMap::new();
925        let body = Bytes::new();
926        let detected = detect_service(&headers, &query, &body).unwrap();
927        assert_eq!(detected.service, "bedrock");
928        assert_eq!(detected.protocol, AwsProtocol::RestJson);
929    }
930
931    #[test]
932    fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
933        // Same alias normalization on the presigned-URL path: a request
934        // signed with bedrock-runtime in the X-Amz-Credential query param
935        // must still resolve to the bedrock service handler.
936        let headers = HeaderMap::new();
937        let mut query = HashMap::new();
938        query.insert(
939            "X-Amz-Credential".to_string(),
940            "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
941        );
942        let body = Bytes::new();
943        let detected = detect_service(&headers, &query, &body).unwrap();
944        assert_eq!(detected.service, "bedrock");
945        assert_eq!(detected.protocol, AwsProtocol::RestJson);
946    }
947
948    #[test]
949    fn parse_routing_host_localstack_basic() {
950        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
951        assert_eq!(h.service, "sqs");
952        assert_eq!(h.region, "us-east-1");
953        assert!(h.bucket.is_none());
954    }
955
956    #[test]
957    fn parse_routing_host_localstack_with_port() {
958        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
959        assert_eq!(h.service, "lambda");
960        assert_eq!(h.region, "eu-west-1");
961        assert!(h.bucket.is_none());
962    }
963
964    #[test]
965    fn parse_routing_host_case_insensitive() {
966        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
967        assert_eq!(h.service, "sqs");
968        assert_eq!(h.region, "us-east-1");
969
970        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
971        assert_eq!(h.service, "lambda");
972        assert_eq!(h.region, "us-east-1");
973    }
974
975    #[test]
976    fn parse_routing_host_localstack_s3_virtual_hosted() {
977        let h =
978            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
979        assert_eq!(h.service, "s3");
980        assert_eq!(h.region, "us-east-1");
981        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
982    }
983
984    #[test]
985    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
986        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
987        assert_eq!(h.service, "s3");
988        assert_eq!(h.region, "us-east-1");
989        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
990    }
991
992    #[test]
993    fn parse_routing_host_aws_service_region() {
994        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
995        assert_eq!(h.service, "sqs");
996        assert_eq!(h.region, "us-east-1");
997        assert!(h.bucket.is_none());
998
999        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1000        assert_eq!(h.service, "dynamodb");
1001        assert_eq!(h.region, "eu-west-2");
1002    }
1003
1004    #[test]
1005    fn parse_routing_host_aws_s3_path_style_modern() {
1006        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1007        assert_eq!(h.service, "s3");
1008        assert_eq!(h.region, "us-east-1");
1009        assert!(h.bucket.is_none());
1010    }
1011
1012    #[test]
1013    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1014        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1015        assert_eq!(h.service, "s3");
1016        assert_eq!(h.region, "us-east-1");
1017        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1018    }
1019
1020    #[test]
1021    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1022        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1023        assert_eq!(h.service, "s3");
1024        assert_eq!(h.region, "us-east-1");
1025        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1026    }
1027
1028    #[test]
1029    fn parse_routing_host_aws_s3_legacy_global() {
1030        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
1031        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
1032        let h = parse_routing_host("s3.amazonaws.com").unwrap();
1033        assert_eq!(h.service, "s3");
1034        assert_eq!(h.region, "us-east-1");
1035        assert!(h.bucket.is_none());
1036
1037        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1038        assert_eq!(h.service, "s3");
1039        assert_eq!(h.region, "us-east-1");
1040        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1041    }
1042
1043    #[test]
1044    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1045        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
1046        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
1047        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1048        assert_eq!(h.service, "s3");
1049        assert_eq!(h.region, "us-east-1");
1050        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1051    }
1052
1053    #[test]
1054    fn parse_routing_host_aws_s3_dash_separated() {
1055        // Older dash-separated form still served by AWS.
1056        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1057        assert_eq!(h.service, "s3");
1058        assert_eq!(h.region, "us-west-2");
1059        assert!(h.bucket.is_none());
1060
1061        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1062        assert_eq!(h.service, "s3");
1063        assert_eq!(h.region, "us-west-2");
1064        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1065    }
1066
1067    #[test]
1068    fn parse_routing_host_rejects_plain_localhost() {
1069        assert!(parse_routing_host("localhost:4566").is_none());
1070        assert!(parse_routing_host("127.0.0.1:4566").is_none());
1071    }
1072
1073    #[test]
1074    fn parse_routing_host_rejects_unknown_suffix() {
1075        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1076        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1077    }
1078
1079    #[test]
1080    fn parse_routing_host_empty_and_malformed_rejected() {
1081        assert!(parse_routing_host("").is_none());
1082        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1083        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1084        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1085        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1086        assert!(parse_routing_host(".amazonaws.com").is_none());
1087        assert!(parse_routing_host("amazonaws.com").is_none());
1088    }
1089
1090    #[test]
1091    fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1092        // A single-label "s3-accesspoint" host has < 2 labels, so the
1093        // virtual-hosted `len() - 2` slice would underflow and panic without
1094        // the length guard. It must be rejected, not crash the router.
1095        assert!(parse_routing_host("s3-accesspoint").is_none());
1096    }
1097
1098    #[test]
1099    fn detect_service_via_host_for_rest_service() {
1100        let mut headers = HeaderMap::new();
1101        headers.insert(
1102            "host",
1103            "s3.us-east-1.localhost.localstack.cloud:4566"
1104                .parse()
1105                .unwrap(),
1106        );
1107        let query = HashMap::new();
1108        let body = Bytes::new();
1109        let detected = detect_service(&headers, &query, &body).unwrap();
1110        assert_eq!(detected.service, "s3");
1111        assert_eq!(detected.protocol, AwsProtocol::Rest);
1112    }
1113
1114    #[test]
1115    fn detect_service_via_host_for_rest_json_service() {
1116        let mut headers = HeaderMap::new();
1117        headers.insert(
1118            "host",
1119            "lambda.us-east-1.localhost.localstack.cloud:4566"
1120                .parse()
1121                .unwrap(),
1122        );
1123        let query = HashMap::new();
1124        let body = Bytes::new();
1125        let detected = detect_service(&headers, &query, &body).unwrap();
1126        assert_eq!(detected.service, "lambda");
1127        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1128    }
1129
1130    #[test]
1131    fn detect_service_via_host_plus_query_action() {
1132        let mut headers = HeaderMap::new();
1133        headers.insert(
1134            "host",
1135            "sqs.us-east-1.localhost.localstack.cloud:4566"
1136                .parse()
1137                .unwrap(),
1138        );
1139        let mut query = HashMap::new();
1140        query.insert("Action".to_string(), "ListQueues".to_string());
1141        let body = Bytes::new();
1142        let detected = detect_service(&headers, &query, &body).unwrap();
1143        assert_eq!(detected.service, "sqs");
1144        assert_eq!(detected.action, "ListQueues");
1145        assert_eq!(detected.protocol, AwsProtocol::Query);
1146    }
1147
1148    #[test]
1149    fn detect_service_sigv4_wins_over_host() {
1150        let mut headers = HeaderMap::new();
1151        headers.insert(
1152            "authorization",
1153            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1154             SignedHeaders=host, Signature=abc"
1155                .parse()
1156                .unwrap(),
1157        );
1158        headers.insert(
1159            "host",
1160            "lambda.us-east-1.localhost.localstack.cloud:4566"
1161                .parse()
1162                .unwrap(),
1163        );
1164        let query = HashMap::new();
1165        let body = Bytes::new();
1166        let detected = detect_service(&headers, &query, &body).unwrap();
1167        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
1168        assert_eq!(detected.service, "s3");
1169        assert_eq!(detected.protocol, AwsProtocol::Rest);
1170    }
1171
1172    #[test]
1173    fn detect_service_host_for_virtual_hosted_s3() {
1174        let mut headers = HeaderMap::new();
1175        headers.insert(
1176            "host",
1177            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1178                .parse()
1179                .unwrap(),
1180        );
1181        let query = HashMap::new();
1182        let body = Bytes::new();
1183        let detected = detect_service(&headers, &query, &body).unwrap();
1184        assert_eq!(detected.service, "s3");
1185        assert_eq!(detected.protocol, AwsProtocol::Rest);
1186    }
1187}