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        // Virtual-hosted form needs at least {alias}.{region}.s3-accesspoint, i.e.
366        // 3+ labels. A bare "s3-accesspoint" host (1 label) must not reach the
367        // `len() - 2` slice, which would underflow and panic.
368        if labels.len() >= 3 {
369            let bucket = labels[..labels.len() - 2].join(".");
370            return Some(RoutingHost {
371                service: "s3".to_string(),
372                region: labels[labels.len() - 1].to_string(),
373                bucket: Some(bucket),
374            });
375        }
376    }
377
378    // `s3-control.<region>` or `{account-id}.s3-control.<region>` — S3
379    // Control endpoint (access point management).
380    if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
381        return Some(RoutingHost {
382            service: "s3".to_string(),
383            region: last.to_string(),
384            bucket: None,
385        });
386    }
387
388    match labels.len() {
389        // `<service>.<region>` — the common case. Covers `s3.<region>`
390        // path-style S3 too, since the service label falls through here.
391        2 => Some(RoutingHost {
392            service: labels[0].to_string(),
393            region: labels[1].to_string(),
394            bucket: None,
395        }),
396        // `<bucket>.s3.<region>` — modern virtual-hosted S3.
397        n if n >= 3 && labels[n - 2] == "s3" => {
398            let bucket = labels[..n - 2].join(".");
399            Some(RoutingHost {
400                service: "s3".to_string(),
401                region: labels[n - 1].to_string(),
402                bucket: Some(bucket),
403            })
404        }
405        _ => None,
406    }
407}
408
409/// Parse `X-Amz-Target: AWSEvents.PutEvents` -> service=events, action=PutEvents
410/// Parse `X-Amz-Target: AmazonSSM.GetParameter` -> service=ssm, action=GetParameter
411fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
412    let (prefix, action) = target.rsplit_once('.')?;
413
414    let service = match prefix {
415        "AWSEvents" => "events",
416        "AmazonSSM" => "ssm",
417        "AmazonSQS" => "sqs",
418        "AmazonSNS" => "sns",
419        "DynamoDB_20120810" => "dynamodb",
420        "DynamoDBStreams_20120810" => "dynamodbstreams",
421        "Logs_20140328" => "logs",
422        s if s.starts_with("secretsmanager") => "secretsmanager",
423        s if s.starts_with("TrentService") => "kms",
424        s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
425        s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
426        s if s.starts_with("Kinesis_20131202") => "kinesis",
427        s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
428        s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
429        s if s.starts_with("AWSStepFunctions") => "states",
430        s if s.starts_with("AWSOrganizationsV") => "organizations",
431        "CertificateManager" => "acm",
432        "AnyScaleFrontendService" => "application-autoscaling",
433        // Match the WAFv2 target version exactly so legacy WAF Classic
434        // (`AWSWAF_*` without the `_20190729` suffix) doesn't get routed here.
435        "AWSWAF_20190729" => "wafv2",
436        "AmazonAthena" => "athena",
437        s if s.starts_with("Firehose_") => "firehose",
438        "AWSGlue" => "glue",
439        _ => return None,
440    };
441
442    Some(DetectedRequest {
443        service: service.to_string(),
444        action: action.to_string(),
445        protocol: AwsProtocol::Json,
446    })
447}
448
449/// Returns the REST protocol variant for a service, or None if not a REST service.
450fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
451    if REST_XML_SERVICES.contains(&service) {
452        Some(AwsProtocol::Rest)
453    } else if REST_JSON_SERVICES.contains(&service) {
454        Some(AwsProtocol::RestJson)
455    } else {
456        None
457    }
458}
459
460/// Infer service from the action name when no SigV4 auth is present.
461/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
462/// do not require authentication and won't have an Authorization header.
463fn infer_service_from_action(action: &str) -> Option<String> {
464    match action {
465        "AssumeRole"
466        | "AssumeRoleWithSAML"
467        | "AssumeRoleWithWebIdentity"
468        | "GetCallerIdentity"
469        | "GetSessionToken"
470        | "GetFederationToken"
471        | "GetAccessKeyInfo"
472        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
473        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
474        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
475        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
476        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
477        // SES v1 (Query protocol)
478        "VerifyEmailIdentity"
479        | "VerifyDomainIdentity"
480        | "VerifyDomainDkim"
481        | "ListIdentities"
482        | "GetIdentityVerificationAttributes"
483        | "GetIdentityDkimAttributes"
484        | "DeleteIdentity"
485        | "SetIdentityDkimEnabled"
486        | "SetIdentityNotificationTopic"
487        | "SetIdentityFeedbackForwardingEnabled"
488        | "GetIdentityNotificationAttributes"
489        | "GetIdentityMailFromDomainAttributes"
490        | "SetIdentityMailFromDomain"
491        | "SendEmail"
492        | "SendRawEmail"
493        | "SendTemplatedEmail"
494        | "SendBulkTemplatedEmail"
495        | "CreateTemplate"
496        | "GetTemplate"
497        | "ListTemplates"
498        | "DeleteTemplate"
499        | "UpdateTemplate"
500        | "CreateConfigurationSet"
501        | "DeleteConfigurationSet"
502        | "DescribeConfigurationSet"
503        | "ListConfigurationSets"
504        | "CreateConfigurationSetEventDestination"
505        | "UpdateConfigurationSetEventDestination"
506        | "DeleteConfigurationSetEventDestination"
507        | "GetSendQuota"
508        | "GetSendStatistics"
509        | "GetAccountSendingEnabled"
510        | "CreateReceiptRuleSet"
511        | "DeleteReceiptRuleSet"
512        | "DescribeReceiptRuleSet"
513        | "ListReceiptRuleSets"
514        | "CloneReceiptRuleSet"
515        | "SetActiveReceiptRuleSet"
516        | "ReorderReceiptRuleSet"
517        | "CreateReceiptRule"
518        | "DeleteReceiptRule"
519        | "DescribeReceiptRule"
520        | "UpdateReceiptRule"
521        | "CreateReceiptFilter"
522        | "DeleteReceiptFilter"
523        | "ListReceiptFilters" => Some("ses".to_string()),
524        _ => None,
525    }
526}
527
528/// Extract service name from the SigV4 Authorization header credential scope.
529fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
530    let auth = headers.get("authorization")?.to_str().ok()?;
531    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
532    Some(normalize_service_name(&info.service).to_string())
533}
534
535/// Map AWS service-name aliases that share path namespace and handlers
536/// to the canonical form used by fakecloud's service registry.
537///
538/// AWS uses `bedrock-runtime` in the SigV4 credential scope of runtime
539/// API calls (`InvokeModel`, `ApplyGuardrail`, etc.) but the REST paths
540/// (e.g. `POST /guardrail/{id}/version/{ver}/apply`) live under the same
541/// `BedrockService` handler that owns the control-plane `bedrock` paths.
542/// Without normalization, `detect_service` returns `None` for
543/// `bedrock-runtime` (not in `REST_JSON_SERVICES`), the central
544/// dispatcher falls back to API Gateway, and `/guardrail/...` 404s with
545/// `NotFoundException: Stage not found: guardrail`. See issue #1232.
546fn normalize_service_name(service: &str) -> &str {
547    match service {
548        "bedrock-runtime" => "bedrock",
549        // Real AWS API Gateway V2 SDK signs with `apigateway` as the SigV4
550        // service (per the model's `aws.api#service.endpointPrefix`), but
551        // tools driven by the Smithy service shape name (including our own
552        // conformance probe) may send `apigatewayv2`. Both refer to the
553        // same fakecloud service registry entry — the v2 handler is path-
554        // routed under `/v2/...` and the v1 handler under `/restapis/...`,
555        // both reachable behind the `apigateway` SigV4 service.
556        "apigatewayv2" => "apigateway",
557        other => other,
558    }
559}
560
561/// Parse form-encoded body into key-value pairs.
562pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
563    decode_form_urlencoded(body)
564}
565
566fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
567    let s = std::str::from_utf8(input).unwrap_or("");
568    let mut result = HashMap::new();
569    for pair in s.split('&') {
570        if pair.is_empty() {
571            continue;
572        }
573        let (key, value) = match pair.find('=') {
574            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
575            None => (pair, ""),
576        };
577        result.insert(url_decode(key), url_decode(value));
578    }
579    result
580}
581
582fn url_decode(input: &str) -> String {
583    let mut result = String::with_capacity(input.len());
584    let mut bytes = input.bytes();
585    while let Some(b) = bytes.next() {
586        match b {
587            b'+' => result.push(' '),
588            b'%' => {
589                let high = bytes.next().and_then(from_hex);
590                let low = bytes.next().and_then(from_hex);
591                if let (Some(h), Some(l)) = (high, low) {
592                    result.push((h << 4 | l) as char);
593                }
594            }
595            _ => result.push(b as char),
596        }
597    }
598    result
599}
600
601fn from_hex(b: u8) -> Option<u8> {
602    match b {
603        b'0'..=b'9' => Some(b - b'0'),
604        b'a'..=b'f' => Some(b - b'a' + 10),
605        b'A'..=b'F' => Some(b - b'A' + 10),
606        _ => None,
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn parse_amz_target_events() {
616        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
617        assert_eq!(result.service, "events");
618        assert_eq!(result.action, "PutEvents");
619        assert_eq!(result.protocol, AwsProtocol::Json);
620    }
621
622    #[test]
623    fn parse_amz_target_ssm() {
624        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
625        assert_eq!(result.service, "ssm");
626        assert_eq!(result.action, "GetParameter");
627    }
628
629    #[test]
630    fn parse_amz_target_kinesis() {
631        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
632        assert_eq!(result.service, "kinesis");
633        assert_eq!(result.action, "ListStreams");
634        assert_eq!(result.protocol, AwsProtocol::Json);
635    }
636
637    #[test]
638    fn parse_query_body_basic() {
639        let body = Bytes::from(
640            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
641        );
642        let params = parse_query_body(&body);
643        assert_eq!(params.get("Action").unwrap(), "SendMessage");
644        assert_eq!(params.get("MessageBody").unwrap(), "hello");
645    }
646
647    #[test]
648    fn parse_query_body_empty_returns_empty_map() {
649        let body = Bytes::from("");
650        let params = parse_query_body(&body);
651        assert!(params.is_empty());
652    }
653
654    #[test]
655    fn parse_query_body_duplicate_keys_last_wins() {
656        let body = Bytes::from("key=a&key=b");
657        let params = parse_query_body(&body);
658        assert_eq!(params.get("key").unwrap(), "b");
659    }
660
661    #[test]
662    fn parse_query_body_single_key() {
663        let body = Bytes::from("key=value");
664        let params = parse_query_body(&body);
665        assert_eq!(params.get("key").unwrap(), "value");
666    }
667
668    #[test]
669    fn parse_amz_target_ecs() {
670        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
671        assert_eq!(result.service, "ecs");
672        assert_eq!(result.action, "ListClusters");
673        assert_eq!(result.protocol, AwsProtocol::Json);
674    }
675
676    #[test]
677    fn parse_amz_target_invalid_returns_none() {
678        assert!(parse_amz_target("NoDotHere").is_none());
679        assert!(parse_amz_target("").is_none());
680    }
681
682    #[test]
683    fn parse_amz_target_various_prefixes() {
684        assert_eq!(
685            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
686            "sqs"
687        );
688        assert_eq!(
689            parse_amz_target("AmazonSNS.Publish").unwrap().service,
690            "sns"
691        );
692        assert_eq!(
693            parse_amz_target("DynamoDB_20120810.GetItem")
694                .unwrap()
695                .service,
696            "dynamodb"
697        );
698        assert_eq!(
699            parse_amz_target("Logs_20140328.PutLogEvents")
700                .unwrap()
701                .service,
702            "logs"
703        );
704        assert_eq!(
705            parse_amz_target("secretsmanager.GetSecretValue")
706                .unwrap()
707                .service,
708            "secretsmanager"
709        );
710        assert_eq!(
711            parse_amz_target("TrentService.Encrypt").unwrap().service,
712            "kms"
713        );
714        assert_eq!(
715            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
716                .unwrap()
717                .service,
718            "cognito-idp"
719        );
720        assert_eq!(
721            parse_amz_target("AWSStepFunctions.StartExecution")
722                .unwrap()
723                .service,
724            "states"
725        );
726        assert_eq!(
727            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
728                .unwrap()
729                .service,
730            "organizations"
731        );
732        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
733    }
734
735    #[test]
736    fn infer_service_from_action_maps_sts() {
737        assert_eq!(
738            infer_service_from_action("AssumeRole").as_deref(),
739            Some("sts")
740        );
741        assert_eq!(
742            infer_service_from_action("GetCallerIdentity").as_deref(),
743            Some("sts")
744        );
745    }
746
747    #[test]
748    fn infer_service_from_action_maps_iam() {
749        assert_eq!(
750            infer_service_from_action("CreateUser").as_deref(),
751            Some("iam")
752        );
753        assert_eq!(
754            infer_service_from_action("ListRoles").as_deref(),
755            Some("iam")
756        );
757    }
758
759    #[test]
760    fn infer_service_from_action_maps_ses() {
761        assert_eq!(
762            infer_service_from_action("SendEmail").as_deref(),
763            Some("ses")
764        );
765        assert_eq!(
766            infer_service_from_action("ListIdentities").as_deref(),
767            Some("ses")
768        );
769    }
770
771    #[test]
772    fn infer_service_from_action_unknown_returns_none() {
773        assert!(infer_service_from_action("NotARealAction").is_none());
774    }
775
776    #[test]
777    fn rest_protocol_for_returns_none_for_non_rest_service() {
778        assert!(rest_protocol_for("sqs").is_none());
779    }
780
781    #[test]
782    fn url_decode_handles_percent_and_plus() {
783        assert_eq!(url_decode("hello+world"), "hello world");
784        assert_eq!(url_decode("hello%20world"), "hello world");
785        assert_eq!(url_decode("100%25"), "100%");
786    }
787
788    #[test]
789    fn url_decode_ignores_malformed_percent() {
790        assert_eq!(url_decode("%ZZ"), "");
791    }
792
793    #[test]
794    fn from_hex_valid_digits() {
795        assert_eq!(from_hex(b'0'), Some(0));
796        assert_eq!(from_hex(b'9'), Some(9));
797        assert_eq!(from_hex(b'a'), Some(10));
798        assert_eq!(from_hex(b'F'), Some(15));
799    }
800
801    #[test]
802    fn from_hex_invalid_returns_none() {
803        assert!(from_hex(b'g').is_none());
804        assert!(from_hex(b' ').is_none());
805    }
806
807    #[test]
808    fn detect_service_via_amz_target() {
809        let mut headers = HeaderMap::new();
810        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
811        let query = HashMap::new();
812        let body = Bytes::new();
813        let detected = detect_service(&headers, &query, &body).unwrap();
814        assert_eq!(detected.service, "ssm");
815        assert_eq!(detected.action, "GetParameter");
816    }
817
818    #[test]
819    fn detect_service_via_query_action_with_inferred_service() {
820        let headers = HeaderMap::new();
821        let mut query = HashMap::new();
822        query.insert("Action".to_string(), "AssumeRole".to_string());
823        let body = Bytes::new();
824        let detected = detect_service(&headers, &query, &body).unwrap();
825        assert_eq!(detected.service, "sts");
826        assert_eq!(detected.action, "AssumeRole");
827        assert_eq!(detected.protocol, AwsProtocol::Query);
828    }
829
830    #[test]
831    fn detect_service_via_form_body() {
832        let headers = HeaderMap::new();
833        let query = HashMap::new();
834        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
835        let detected = detect_service(&headers, &query, &body).unwrap();
836        assert_eq!(detected.service, "ses");
837        assert_eq!(detected.action, "SendEmail");
838    }
839
840    #[test]
841    fn detect_service_via_sigv2_presigned() {
842        let headers = HeaderMap::new();
843        let mut query = HashMap::new();
844        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
845        query.insert("Signature".to_string(), "sig".to_string());
846        query.insert("Expires".to_string(), "1234567890".to_string());
847        let body = Bytes::new();
848        let detected = detect_service(&headers, &query, &body).unwrap();
849        assert_eq!(detected.service, "s3");
850        assert_eq!(detected.protocol, AwsProtocol::Rest);
851    }
852
853    #[test]
854    fn detect_service_via_sigv4_presigned_credential() {
855        let headers = HeaderMap::new();
856        let mut query = HashMap::new();
857        query.insert(
858            "X-Amz-Credential".to_string(),
859            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
860        );
861        let body = Bytes::new();
862        let detected = detect_service(&headers, &query, &body).unwrap();
863        assert_eq!(detected.service, "s3");
864        assert_eq!(detected.protocol, AwsProtocol::Rest);
865    }
866
867    #[test]
868    fn detect_service_unknown_returns_none() {
869        let headers = HeaderMap::new();
870        let query = HashMap::new();
871        let body = Bytes::new();
872        assert!(detect_service(&headers, &query, &body).is_none());
873    }
874
875    #[test]
876    fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
877        // Real AWS API Gateway V2 SDK signs with `apigateway` per the
878        // model's `endpointPrefix`, but Smithy-driven tooling (including
879        // our conformance probe) sends `apigatewayv2`. Both routes resolve
880        // to the same fakecloud service registry entry.
881        assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
882    }
883
884    #[test]
885    fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
886        // The bedrock-runtime credential scope shares path namespace with
887        // the bedrock control plane (`POST /guardrail/{id}/version/{ver}/apply`
888        // is implemented under BedrockService). Routing must resolve to
889        // the bedrock service so the existing handlers run. See #1232.
890        assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
891    }
892
893    #[test]
894    fn normalize_service_name_passes_through_unaliased_services() {
895        // Every service that isn't on the alias list must round-trip
896        // unchanged — including the canonical bedrock name itself, so a
897        // plain bedrock request takes the same code path it always has.
898        assert_eq!(normalize_service_name("bedrock"), "bedrock");
899        assert_eq!(normalize_service_name("s3"), "s3");
900        assert_eq!(normalize_service_name("lambda"), "lambda");
901        assert_eq!(normalize_service_name(""), "");
902        assert_eq!(
903            normalize_service_name("unknown-future-service"),
904            "unknown-future-service"
905        );
906    }
907
908    #[test]
909    fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
910        // SigV4 auth header carries `bedrock-runtime` in the credential
911        // scope; dispatcher must route to the bedrock service handler so
912        // `/guardrail/...` lands on `BedrockService` instead of falling
913        // through to API Gateway.
914        let mut headers = HeaderMap::new();
915        headers.insert(
916            "authorization",
917            "AWS4-HMAC-SHA256 \
918             Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
919             SignedHeaders=host, Signature=abc"
920                .parse()
921                .unwrap(),
922        );
923        let query = HashMap::new();
924        let body = Bytes::new();
925        let detected = detect_service(&headers, &query, &body).unwrap();
926        assert_eq!(detected.service, "bedrock");
927        assert_eq!(detected.protocol, AwsProtocol::RestJson);
928    }
929
930    #[test]
931    fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
932        // Same alias normalization on the presigned-URL path: a request
933        // signed with bedrock-runtime in the X-Amz-Credential query param
934        // must still resolve to the bedrock service handler.
935        let headers = HeaderMap::new();
936        let mut query = HashMap::new();
937        query.insert(
938            "X-Amz-Credential".to_string(),
939            "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
940        );
941        let body = Bytes::new();
942        let detected = detect_service(&headers, &query, &body).unwrap();
943        assert_eq!(detected.service, "bedrock");
944        assert_eq!(detected.protocol, AwsProtocol::RestJson);
945    }
946
947    #[test]
948    fn parse_routing_host_localstack_basic() {
949        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
950        assert_eq!(h.service, "sqs");
951        assert_eq!(h.region, "us-east-1");
952        assert!(h.bucket.is_none());
953    }
954
955    #[test]
956    fn parse_routing_host_localstack_with_port() {
957        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
958        assert_eq!(h.service, "lambda");
959        assert_eq!(h.region, "eu-west-1");
960        assert!(h.bucket.is_none());
961    }
962
963    #[test]
964    fn parse_routing_host_case_insensitive() {
965        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
966        assert_eq!(h.service, "sqs");
967        assert_eq!(h.region, "us-east-1");
968
969        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
970        assert_eq!(h.service, "lambda");
971        assert_eq!(h.region, "us-east-1");
972    }
973
974    #[test]
975    fn parse_routing_host_localstack_s3_virtual_hosted() {
976        let h =
977            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
978        assert_eq!(h.service, "s3");
979        assert_eq!(h.region, "us-east-1");
980        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
981    }
982
983    #[test]
984    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
985        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
986        assert_eq!(h.service, "s3");
987        assert_eq!(h.region, "us-east-1");
988        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
989    }
990
991    #[test]
992    fn parse_routing_host_aws_service_region() {
993        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
994        assert_eq!(h.service, "sqs");
995        assert_eq!(h.region, "us-east-1");
996        assert!(h.bucket.is_none());
997
998        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
999        assert_eq!(h.service, "dynamodb");
1000        assert_eq!(h.region, "eu-west-2");
1001    }
1002
1003    #[test]
1004    fn parse_routing_host_aws_s3_path_style_modern() {
1005        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1006        assert_eq!(h.service, "s3");
1007        assert_eq!(h.region, "us-east-1");
1008        assert!(h.bucket.is_none());
1009    }
1010
1011    #[test]
1012    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1013        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1014        assert_eq!(h.service, "s3");
1015        assert_eq!(h.region, "us-east-1");
1016        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1017    }
1018
1019    #[test]
1020    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1021        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1022        assert_eq!(h.service, "s3");
1023        assert_eq!(h.region, "us-east-1");
1024        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1025    }
1026
1027    #[test]
1028    fn parse_routing_host_aws_s3_legacy_global() {
1029        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
1030        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
1031        let h = parse_routing_host("s3.amazonaws.com").unwrap();
1032        assert_eq!(h.service, "s3");
1033        assert_eq!(h.region, "us-east-1");
1034        assert!(h.bucket.is_none());
1035
1036        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1037        assert_eq!(h.service, "s3");
1038        assert_eq!(h.region, "us-east-1");
1039        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1040    }
1041
1042    #[test]
1043    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1044        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
1045        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
1046        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1047        assert_eq!(h.service, "s3");
1048        assert_eq!(h.region, "us-east-1");
1049        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1050    }
1051
1052    #[test]
1053    fn parse_routing_host_aws_s3_dash_separated() {
1054        // Older dash-separated form still served by AWS.
1055        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1056        assert_eq!(h.service, "s3");
1057        assert_eq!(h.region, "us-west-2");
1058        assert!(h.bucket.is_none());
1059
1060        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1061        assert_eq!(h.service, "s3");
1062        assert_eq!(h.region, "us-west-2");
1063        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1064    }
1065
1066    #[test]
1067    fn parse_routing_host_rejects_plain_localhost() {
1068        assert!(parse_routing_host("localhost:4566").is_none());
1069        assert!(parse_routing_host("127.0.0.1:4566").is_none());
1070    }
1071
1072    #[test]
1073    fn parse_routing_host_rejects_unknown_suffix() {
1074        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1075        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1076    }
1077
1078    #[test]
1079    fn parse_routing_host_empty_and_malformed_rejected() {
1080        assert!(parse_routing_host("").is_none());
1081        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1082        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1083        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1084        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1085        assert!(parse_routing_host(".amazonaws.com").is_none());
1086        assert!(parse_routing_host("amazonaws.com").is_none());
1087    }
1088
1089    #[test]
1090    fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1091        // A single-label "s3-accesspoint" host has < 2 labels, so the
1092        // virtual-hosted `len() - 2` slice would underflow and panic without
1093        // the length guard. It must be rejected, not crash the router.
1094        assert!(parse_routing_host("s3-accesspoint").is_none());
1095    }
1096
1097    #[test]
1098    fn detect_service_via_host_for_rest_service() {
1099        let mut headers = HeaderMap::new();
1100        headers.insert(
1101            "host",
1102            "s3.us-east-1.localhost.localstack.cloud:4566"
1103                .parse()
1104                .unwrap(),
1105        );
1106        let query = HashMap::new();
1107        let body = Bytes::new();
1108        let detected = detect_service(&headers, &query, &body).unwrap();
1109        assert_eq!(detected.service, "s3");
1110        assert_eq!(detected.protocol, AwsProtocol::Rest);
1111    }
1112
1113    #[test]
1114    fn detect_service_via_host_for_rest_json_service() {
1115        let mut headers = HeaderMap::new();
1116        headers.insert(
1117            "host",
1118            "lambda.us-east-1.localhost.localstack.cloud:4566"
1119                .parse()
1120                .unwrap(),
1121        );
1122        let query = HashMap::new();
1123        let body = Bytes::new();
1124        let detected = detect_service(&headers, &query, &body).unwrap();
1125        assert_eq!(detected.service, "lambda");
1126        assert_eq!(detected.protocol, AwsProtocol::RestJson);
1127    }
1128
1129    #[test]
1130    fn detect_service_via_host_plus_query_action() {
1131        let mut headers = HeaderMap::new();
1132        headers.insert(
1133            "host",
1134            "sqs.us-east-1.localhost.localstack.cloud:4566"
1135                .parse()
1136                .unwrap(),
1137        );
1138        let mut query = HashMap::new();
1139        query.insert("Action".to_string(), "ListQueues".to_string());
1140        let body = Bytes::new();
1141        let detected = detect_service(&headers, &query, &body).unwrap();
1142        assert_eq!(detected.service, "sqs");
1143        assert_eq!(detected.action, "ListQueues");
1144        assert_eq!(detected.protocol, AwsProtocol::Query);
1145    }
1146
1147    #[test]
1148    fn detect_service_sigv4_wins_over_host() {
1149        let mut headers = HeaderMap::new();
1150        headers.insert(
1151            "authorization",
1152            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1153             SignedHeaders=host, Signature=abc"
1154                .parse()
1155                .unwrap(),
1156        );
1157        headers.insert(
1158            "host",
1159            "lambda.us-east-1.localhost.localstack.cloud:4566"
1160                .parse()
1161                .unwrap(),
1162        );
1163        let query = HashMap::new();
1164        let body = Bytes::new();
1165        let detected = detect_service(&headers, &query, &body).unwrap();
1166        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
1167        assert_eq!(detected.service, "s3");
1168        assert_eq!(detected.protocol, AwsProtocol::Rest);
1169    }
1170
1171    #[test]
1172    fn detect_service_host_for_virtual_hosted_s3() {
1173        let mut headers = HeaderMap::new();
1174        headers.insert(
1175            "host",
1176            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1177                .parse()
1178                .unwrap(),
1179        );
1180        let query = HashMap::new();
1181        let body = Bytes::new();
1182        let detected = detect_service(&headers, &query, &body).unwrap();
1183        assert_eq!(detected.service, "s3");
1184        assert_eq!(detected.protocol, AwsProtocol::Rest);
1185    }
1186}