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