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