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"];
24
25/// Services that use REST protocol with JSON responses (detected from SigV4 credential scope).
26const REST_JSON_SERVICES: &[&str] = &["lambda", "ses", "apigateway", "bedrock", "scheduler"];
27
28/// Detected service name and action from an incoming HTTP request.
29#[derive(Debug)]
30pub struct DetectedRequest {
31    pub service: String,
32    pub action: String,
33    pub protocol: AwsProtocol,
34}
35
36/// Detect the target service and action from HTTP request components.
37pub fn detect_service(
38    headers: &HeaderMap,
39    query_params: &HashMap<String, String>,
40    body: &Bytes,
41) -> Option<DetectedRequest> {
42    // 1. Check X-Amz-Target header (JSON protocol)
43    if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
44        return parse_amz_target(target);
45    }
46
47    // 2. Check for Query protocol (Action parameter in query string or form body)
48    if let Some(action) = query_params.get("Action") {
49        let service = extract_service_from_auth(headers)
50            .or_else(|| infer_service_from_action(action))
51            .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
52        if let Some(service) = service {
53            return Some(DetectedRequest {
54                service,
55                action: action.clone(),
56                protocol: AwsProtocol::Query,
57            });
58        }
59    }
60
61    // 3. Try form-encoded body
62    {
63        let form_params = decode_form_urlencoded(body);
64
65        if let Some(action) = form_params.get("Action") {
66            let service = extract_service_from_auth(headers)
67                .or_else(|| infer_service_from_action(action))
68                .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
69            if let Some(service) = service {
70                return Some(DetectedRequest {
71                    service,
72                    action: action.clone(),
73                    protocol: AwsProtocol::Query,
74                });
75            }
76        }
77    }
78
79    // 4. Fallback: check auth header for REST-style services (S3, Lambda, SES, etc.)
80    if let Some(service) = extract_service_from_auth(headers) {
81        if let Some(protocol) = rest_protocol_for(&service) {
82            return Some(DetectedRequest {
83                service,
84                action: String::new(), // REST services determine action from method+path
85                protocol,
86            });
87        }
88    }
89
90    // 5. Check query params for presigned URL auth (X-Amz-Credential for SigV4)
91    if let Some(credential) = query_params.get("X-Amz-Credential") {
92        // Format: AKID/date/region/service/aws4_request
93        let parts: Vec<&str> = credential.split('/').collect();
94        if parts.len() >= 4 {
95            let service = parts[3].to_string();
96            if let Some(protocol) = rest_protocol_for(&service) {
97                return Some(DetectedRequest {
98                    service,
99                    action: String::new(),
100                    protocol,
101                });
102            }
103        }
104    }
105
106    // 6. Check for SigV2-style presigned URL (AWSAccessKeyId + Signature + Expires)
107    //    Only match when all three SigV2 presigned-URL parameters are present so
108    //    we don't accidentally claim non-S3 requests.
109    if query_params.contains_key("AWSAccessKeyId")
110        && query_params.contains_key("Signature")
111        && query_params.contains_key("Expires")
112    {
113        return Some(DetectedRequest {
114            service: "s3".to_string(),
115            action: String::new(),
116            protocol: AwsProtocol::Rest,
117        });
118    }
119
120    // 7. Fallback: unsigned REST-style request carrying a LocalStack-shaped
121    //    Host header. Lets fixtures and curl-style probes reach the right
122    //    service without SigV4; signed requests were already handled in step 4.
123    if let Some(host_info) = parse_routing_host_from_headers(headers) {
124        if let Some(protocol) = rest_protocol_for(&host_info.service) {
125            return Some(DetectedRequest {
126                service: host_info.service,
127                action: String::new(),
128                protocol,
129            });
130        }
131    }
132
133    None
134}
135
136/// Service + region (and optional bucket) decoded from a `Host` header.
137/// Covers both the LocalStack hostname convention
138/// (`<service>.<region>.localhost.localstack.cloud[:port]`,
139/// `<bucket>.s3.<region>.localhost.localstack.cloud[:port]`) and real AWS
140/// service hostnames (`<service>.<region>.amazonaws.com`, S3 path-style
141/// and virtual-hosted-style including the legacy no-region
142/// `s3.amazonaws.com` / `<bucket>.s3.amazonaws.com` forms and the older
143/// dash-separated `s3-<region>.amazonaws.com` form).
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct RoutingHost {
146    pub service: String,
147    pub region: String,
148    /// Set only for virtual-hosted-style S3 hostnames.
149    pub bucket: Option<String>,
150}
151
152const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
153const AWS_SUFFIX: &str = ".amazonaws.com";
154
155/// Parse a `Host` header value for a LocalStack- or AWS-shaped hostname.
156/// Returns `None` for anything that doesn't match — callers fall through
157/// to their existing detection path.
158pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
159    let hostname = host.split(':').next()?;
160    if hostname.is_empty() {
161        return None;
162    }
163    let hostname = hostname.to_ascii_lowercase();
164    if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
165        return parse_localstack_prefix(prefix);
166    }
167    if hostname == "amazonaws.com" {
168        return None;
169    }
170    if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
171        return parse_aws_prefix(prefix);
172    }
173    None
174}
175
176/// Pull the `Host` header and parse it with [`parse_routing_host`].
177pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
178    let host = headers.get("host")?.to_str().ok()?;
179    parse_routing_host(host)
180}
181
182fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
183    if prefix.is_empty() {
184        return None;
185    }
186    let labels: Vec<&str> = prefix.split('.').collect();
187    if labels.iter().any(|l| l.is_empty()) {
188        return None;
189    }
190    match labels.len() {
191        2 => Some(RoutingHost {
192            service: labels[0].to_string(),
193            region: labels[1].to_string(),
194            bucket: None,
195        }),
196        n if n >= 3 && labels[n - 2] == "s3" => {
197            let bucket = labels[..n - 2].join(".");
198            Some(RoutingHost {
199                service: "s3".to_string(),
200                region: labels[n - 1].to_string(),
201                bucket: Some(bucket),
202            })
203        }
204        _ => None,
205    }
206}
207
208/// Parse the prefix before `.amazonaws.com`.
209///
210/// Handles every variant AWS has shipped for the common REST/Query services:
211///
212/// - `<service>.<region>` — modern regional endpoint (most services).
213/// - `s3.<region>` — modern path-style S3.
214/// - `<bucket>.s3.<region>` — modern virtual-hosted S3 (bucket may contain dots).
215/// - `s3` — legacy S3 global endpoint (implicitly `us-east-1`).
216/// - `<bucket>.s3` — legacy virtual-hosted S3 (implicitly `us-east-1`).
217/// - `s3-<region>` — older dash-separated path-style S3.
218/// - `<bucket>.s3-<region>` — older dash-separated virtual-hosted S3.
219fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
220    if prefix.is_empty() {
221        return None;
222    }
223    let labels: Vec<&str> = prefix.split('.').collect();
224    if labels.iter().any(|l| l.is_empty()) {
225        return None;
226    }
227    let last = *labels.last()?;
228
229    // `s3-<region>` as the last label: dash-separated S3. Bucket, if any,
230    // is whatever precedes it.
231    if let Some(region) = last.strip_prefix("s3-") {
232        if !region.is_empty() {
233            let bucket = if labels.len() >= 2 {
234                Some(labels[..labels.len() - 1].join("."))
235            } else {
236                None
237            };
238            return Some(RoutingHost {
239                service: "s3".to_string(),
240                region: region.to_string(),
241                bucket,
242            });
243        }
244    }
245
246    // Legacy global S3: last label is `s3`, no region present. `s3` on its
247    // own is the path-style global endpoint; anything preceding it is the
248    // bucket (including dotted names like `a.b.s3.amazonaws.com`).
249    if last == "s3" {
250        if labels.len() == 1 {
251            return Some(RoutingHost {
252                service: "s3".to_string(),
253                region: "us-east-1".to_string(),
254                bucket: None,
255            });
256        }
257        return Some(RoutingHost {
258            service: "s3".to_string(),
259            region: "us-east-1".to_string(),
260            bucket: Some(labels[..labels.len() - 1].join(".")),
261        });
262    }
263
264    match labels.len() {
265        // `<service>.<region>` — the common case. Covers `s3.<region>`
266        // path-style S3 too, since the service label falls through here.
267        2 => Some(RoutingHost {
268            service: labels[0].to_string(),
269            region: labels[1].to_string(),
270            bucket: None,
271        }),
272        // `<bucket>.s3.<region>` — modern virtual-hosted S3.
273        n if n >= 3 && labels[n - 2] == "s3" => {
274            let bucket = labels[..n - 2].join(".");
275            Some(RoutingHost {
276                service: "s3".to_string(),
277                region: labels[n - 1].to_string(),
278                bucket: Some(bucket),
279            })
280        }
281        _ => None,
282    }
283}
284
285/// Parse `X-Amz-Target: AWSEvents.PutEvents` -> service=events, action=PutEvents
286/// Parse `X-Amz-Target: AmazonSSM.GetParameter` -> service=ssm, action=GetParameter
287fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
288    let (prefix, action) = target.rsplit_once('.')?;
289
290    let service = match prefix {
291        "AWSEvents" => "events",
292        "AmazonSSM" => "ssm",
293        "AmazonSQS" => "sqs",
294        "AmazonSNS" => "sns",
295        "DynamoDB_20120810" => "dynamodb",
296        "Logs_20140328" => "logs",
297        s if s.starts_with("secretsmanager") => "secretsmanager",
298        s if s.starts_with("TrentService") => "kms",
299        s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
300        s if s.starts_with("Kinesis_20131202") => "kinesis",
301        s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
302        s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
303        s if s.starts_with("AWSStepFunctions") => "states",
304        s if s.starts_with("AWSOrganizationsV") => "organizations",
305        _ => return None,
306    };
307
308    Some(DetectedRequest {
309        service: service.to_string(),
310        action: action.to_string(),
311        protocol: AwsProtocol::Json,
312    })
313}
314
315/// Returns the REST protocol variant for a service, or None if not a REST service.
316fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
317    if REST_XML_SERVICES.contains(&service) {
318        Some(AwsProtocol::Rest)
319    } else if REST_JSON_SERVICES.contains(&service) {
320        Some(AwsProtocol::RestJson)
321    } else {
322        None
323    }
324}
325
326/// Infer service from the action name when no SigV4 auth is present.
327/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
328/// do not require authentication and won't have an Authorization header.
329fn infer_service_from_action(action: &str) -> Option<String> {
330    match action {
331        "AssumeRole"
332        | "AssumeRoleWithSAML"
333        | "AssumeRoleWithWebIdentity"
334        | "GetCallerIdentity"
335        | "GetSessionToken"
336        | "GetFederationToken"
337        | "GetAccessKeyInfo"
338        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
339        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
340        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
341        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
342        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
343        // SES v1 (Query protocol)
344        "VerifyEmailIdentity"
345        | "VerifyDomainIdentity"
346        | "VerifyDomainDkim"
347        | "ListIdentities"
348        | "GetIdentityVerificationAttributes"
349        | "GetIdentityDkimAttributes"
350        | "DeleteIdentity"
351        | "SetIdentityDkimEnabled"
352        | "SetIdentityNotificationTopic"
353        | "SetIdentityFeedbackForwardingEnabled"
354        | "GetIdentityNotificationAttributes"
355        | "GetIdentityMailFromDomainAttributes"
356        | "SetIdentityMailFromDomain"
357        | "SendEmail"
358        | "SendRawEmail"
359        | "SendTemplatedEmail"
360        | "SendBulkTemplatedEmail"
361        | "CreateTemplate"
362        | "GetTemplate"
363        | "ListTemplates"
364        | "DeleteTemplate"
365        | "UpdateTemplate"
366        | "CreateConfigurationSet"
367        | "DeleteConfigurationSet"
368        | "DescribeConfigurationSet"
369        | "ListConfigurationSets"
370        | "CreateConfigurationSetEventDestination"
371        | "UpdateConfigurationSetEventDestination"
372        | "DeleteConfigurationSetEventDestination"
373        | "GetSendQuota"
374        | "GetSendStatistics"
375        | "GetAccountSendingEnabled"
376        | "CreateReceiptRuleSet"
377        | "DeleteReceiptRuleSet"
378        | "DescribeReceiptRuleSet"
379        | "ListReceiptRuleSets"
380        | "CloneReceiptRuleSet"
381        | "SetActiveReceiptRuleSet"
382        | "ReorderReceiptRuleSet"
383        | "CreateReceiptRule"
384        | "DeleteReceiptRule"
385        | "DescribeReceiptRule"
386        | "UpdateReceiptRule"
387        | "CreateReceiptFilter"
388        | "DeleteReceiptFilter"
389        | "ListReceiptFilters" => Some("ses".to_string()),
390        _ => None,
391    }
392}
393
394/// Extract service name from the SigV4 Authorization header credential scope.
395fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
396    let auth = headers.get("authorization")?.to_str().ok()?;
397    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
398    Some(info.service)
399}
400
401/// Parse form-encoded body into key-value pairs.
402pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
403    decode_form_urlencoded(body)
404}
405
406fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
407    let s = std::str::from_utf8(input).unwrap_or("");
408    let mut result = HashMap::new();
409    for pair in s.split('&') {
410        if pair.is_empty() {
411            continue;
412        }
413        let (key, value) = match pair.find('=') {
414            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
415            None => (pair, ""),
416        };
417        result.insert(url_decode(key), url_decode(value));
418    }
419    result
420}
421
422fn url_decode(input: &str) -> String {
423    let mut result = String::with_capacity(input.len());
424    let mut bytes = input.bytes();
425    while let Some(b) = bytes.next() {
426        match b {
427            b'+' => result.push(' '),
428            b'%' => {
429                let high = bytes.next().and_then(from_hex);
430                let low = bytes.next().and_then(from_hex);
431                if let (Some(h), Some(l)) = (high, low) {
432                    result.push((h << 4 | l) as char);
433                }
434            }
435            _ => result.push(b as char),
436        }
437    }
438    result
439}
440
441fn from_hex(b: u8) -> Option<u8> {
442    match b {
443        b'0'..=b'9' => Some(b - b'0'),
444        b'a'..=b'f' => Some(b - b'a' + 10),
445        b'A'..=b'F' => Some(b - b'A' + 10),
446        _ => None,
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn parse_amz_target_events() {
456        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
457        assert_eq!(result.service, "events");
458        assert_eq!(result.action, "PutEvents");
459        assert_eq!(result.protocol, AwsProtocol::Json);
460    }
461
462    #[test]
463    fn parse_amz_target_ssm() {
464        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
465        assert_eq!(result.service, "ssm");
466        assert_eq!(result.action, "GetParameter");
467    }
468
469    #[test]
470    fn parse_amz_target_kinesis() {
471        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
472        assert_eq!(result.service, "kinesis");
473        assert_eq!(result.action, "ListStreams");
474        assert_eq!(result.protocol, AwsProtocol::Json);
475    }
476
477    #[test]
478    fn parse_query_body_basic() {
479        let body = Bytes::from(
480            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
481        );
482        let params = parse_query_body(&body);
483        assert_eq!(params.get("Action").unwrap(), "SendMessage");
484        assert_eq!(params.get("MessageBody").unwrap(), "hello");
485    }
486
487    #[test]
488    fn parse_query_body_empty_returns_empty_map() {
489        let body = Bytes::from("");
490        let params = parse_query_body(&body);
491        assert!(params.is_empty());
492    }
493
494    #[test]
495    fn parse_query_body_duplicate_keys_last_wins() {
496        let body = Bytes::from("key=a&key=b");
497        let params = parse_query_body(&body);
498        assert_eq!(params.get("key").unwrap(), "b");
499    }
500
501    #[test]
502    fn parse_query_body_single_key() {
503        let body = Bytes::from("key=value");
504        let params = parse_query_body(&body);
505        assert_eq!(params.get("key").unwrap(), "value");
506    }
507
508    #[test]
509    fn parse_amz_target_ecs() {
510        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
511        assert_eq!(result.service, "ecs");
512        assert_eq!(result.action, "ListClusters");
513        assert_eq!(result.protocol, AwsProtocol::Json);
514    }
515
516    #[test]
517    fn parse_amz_target_invalid_returns_none() {
518        assert!(parse_amz_target("NoDotHere").is_none());
519        assert!(parse_amz_target("").is_none());
520    }
521
522    #[test]
523    fn parse_amz_target_various_prefixes() {
524        assert_eq!(
525            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
526            "sqs"
527        );
528        assert_eq!(
529            parse_amz_target("AmazonSNS.Publish").unwrap().service,
530            "sns"
531        );
532        assert_eq!(
533            parse_amz_target("DynamoDB_20120810.GetItem")
534                .unwrap()
535                .service,
536            "dynamodb"
537        );
538        assert_eq!(
539            parse_amz_target("Logs_20140328.PutLogEvents")
540                .unwrap()
541                .service,
542            "logs"
543        );
544        assert_eq!(
545            parse_amz_target("secretsmanager.GetSecretValue")
546                .unwrap()
547                .service,
548            "secretsmanager"
549        );
550        assert_eq!(
551            parse_amz_target("TrentService.Encrypt").unwrap().service,
552            "kms"
553        );
554        assert_eq!(
555            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
556                .unwrap()
557                .service,
558            "cognito-idp"
559        );
560        assert_eq!(
561            parse_amz_target("AWSStepFunctions.StartExecution")
562                .unwrap()
563                .service,
564            "states"
565        );
566        assert_eq!(
567            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
568                .unwrap()
569                .service,
570            "organizations"
571        );
572        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
573    }
574
575    #[test]
576    fn infer_service_from_action_maps_sts() {
577        assert_eq!(
578            infer_service_from_action("AssumeRole").as_deref(),
579            Some("sts")
580        );
581        assert_eq!(
582            infer_service_from_action("GetCallerIdentity").as_deref(),
583            Some("sts")
584        );
585    }
586
587    #[test]
588    fn infer_service_from_action_maps_iam() {
589        assert_eq!(
590            infer_service_from_action("CreateUser").as_deref(),
591            Some("iam")
592        );
593        assert_eq!(
594            infer_service_from_action("ListRoles").as_deref(),
595            Some("iam")
596        );
597    }
598
599    #[test]
600    fn infer_service_from_action_maps_ses() {
601        assert_eq!(
602            infer_service_from_action("SendEmail").as_deref(),
603            Some("ses")
604        );
605        assert_eq!(
606            infer_service_from_action("ListIdentities").as_deref(),
607            Some("ses")
608        );
609    }
610
611    #[test]
612    fn infer_service_from_action_unknown_returns_none() {
613        assert!(infer_service_from_action("NotARealAction").is_none());
614    }
615
616    #[test]
617    fn rest_protocol_for_returns_none_for_non_rest_service() {
618        assert!(rest_protocol_for("sqs").is_none());
619    }
620
621    #[test]
622    fn url_decode_handles_percent_and_plus() {
623        assert_eq!(url_decode("hello+world"), "hello world");
624        assert_eq!(url_decode("hello%20world"), "hello world");
625        assert_eq!(url_decode("100%25"), "100%");
626    }
627
628    #[test]
629    fn url_decode_ignores_malformed_percent() {
630        assert_eq!(url_decode("%ZZ"), "");
631    }
632
633    #[test]
634    fn from_hex_valid_digits() {
635        assert_eq!(from_hex(b'0'), Some(0));
636        assert_eq!(from_hex(b'9'), Some(9));
637        assert_eq!(from_hex(b'a'), Some(10));
638        assert_eq!(from_hex(b'F'), Some(15));
639    }
640
641    #[test]
642    fn from_hex_invalid_returns_none() {
643        assert!(from_hex(b'g').is_none());
644        assert!(from_hex(b' ').is_none());
645    }
646
647    #[test]
648    fn detect_service_via_amz_target() {
649        let mut headers = HeaderMap::new();
650        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
651        let query = HashMap::new();
652        let body = Bytes::new();
653        let detected = detect_service(&headers, &query, &body).unwrap();
654        assert_eq!(detected.service, "ssm");
655        assert_eq!(detected.action, "GetParameter");
656    }
657
658    #[test]
659    fn detect_service_via_query_action_with_inferred_service() {
660        let headers = HeaderMap::new();
661        let mut query = HashMap::new();
662        query.insert("Action".to_string(), "AssumeRole".to_string());
663        let body = Bytes::new();
664        let detected = detect_service(&headers, &query, &body).unwrap();
665        assert_eq!(detected.service, "sts");
666        assert_eq!(detected.action, "AssumeRole");
667        assert_eq!(detected.protocol, AwsProtocol::Query);
668    }
669
670    #[test]
671    fn detect_service_via_form_body() {
672        let headers = HeaderMap::new();
673        let query = HashMap::new();
674        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
675        let detected = detect_service(&headers, &query, &body).unwrap();
676        assert_eq!(detected.service, "ses");
677        assert_eq!(detected.action, "SendEmail");
678    }
679
680    #[test]
681    fn detect_service_via_sigv2_presigned() {
682        let headers = HeaderMap::new();
683        let mut query = HashMap::new();
684        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
685        query.insert("Signature".to_string(), "sig".to_string());
686        query.insert("Expires".to_string(), "1234567890".to_string());
687        let body = Bytes::new();
688        let detected = detect_service(&headers, &query, &body).unwrap();
689        assert_eq!(detected.service, "s3");
690        assert_eq!(detected.protocol, AwsProtocol::Rest);
691    }
692
693    #[test]
694    fn detect_service_via_sigv4_presigned_credential() {
695        let headers = HeaderMap::new();
696        let mut query = HashMap::new();
697        query.insert(
698            "X-Amz-Credential".to_string(),
699            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
700        );
701        let body = Bytes::new();
702        let detected = detect_service(&headers, &query, &body).unwrap();
703        assert_eq!(detected.service, "s3");
704        assert_eq!(detected.protocol, AwsProtocol::Rest);
705    }
706
707    #[test]
708    fn detect_service_unknown_returns_none() {
709        let headers = HeaderMap::new();
710        let query = HashMap::new();
711        let body = Bytes::new();
712        assert!(detect_service(&headers, &query, &body).is_none());
713    }
714
715    #[test]
716    fn parse_routing_host_localstack_basic() {
717        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
718        assert_eq!(h.service, "sqs");
719        assert_eq!(h.region, "us-east-1");
720        assert!(h.bucket.is_none());
721    }
722
723    #[test]
724    fn parse_routing_host_localstack_with_port() {
725        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
726        assert_eq!(h.service, "lambda");
727        assert_eq!(h.region, "eu-west-1");
728        assert!(h.bucket.is_none());
729    }
730
731    #[test]
732    fn parse_routing_host_case_insensitive() {
733        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
734        assert_eq!(h.service, "sqs");
735        assert_eq!(h.region, "us-east-1");
736
737        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
738        assert_eq!(h.service, "lambda");
739        assert_eq!(h.region, "us-east-1");
740    }
741
742    #[test]
743    fn parse_routing_host_localstack_s3_virtual_hosted() {
744        let h =
745            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
746        assert_eq!(h.service, "s3");
747        assert_eq!(h.region, "us-east-1");
748        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
749    }
750
751    #[test]
752    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
753        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
754        assert_eq!(h.service, "s3");
755        assert_eq!(h.region, "us-east-1");
756        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
757    }
758
759    #[test]
760    fn parse_routing_host_aws_service_region() {
761        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
762        assert_eq!(h.service, "sqs");
763        assert_eq!(h.region, "us-east-1");
764        assert!(h.bucket.is_none());
765
766        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
767        assert_eq!(h.service, "dynamodb");
768        assert_eq!(h.region, "eu-west-2");
769    }
770
771    #[test]
772    fn parse_routing_host_aws_s3_path_style_modern() {
773        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
774        assert_eq!(h.service, "s3");
775        assert_eq!(h.region, "us-east-1");
776        assert!(h.bucket.is_none());
777    }
778
779    #[test]
780    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
781        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
782        assert_eq!(h.service, "s3");
783        assert_eq!(h.region, "us-east-1");
784        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
785    }
786
787    #[test]
788    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
789        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
790        assert_eq!(h.service, "s3");
791        assert_eq!(h.region, "us-east-1");
792        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
793    }
794
795    #[test]
796    fn parse_routing_host_aws_s3_legacy_global() {
797        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
798        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
799        let h = parse_routing_host("s3.amazonaws.com").unwrap();
800        assert_eq!(h.service, "s3");
801        assert_eq!(h.region, "us-east-1");
802        assert!(h.bucket.is_none());
803
804        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
805        assert_eq!(h.service, "s3");
806        assert_eq!(h.region, "us-east-1");
807        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
808    }
809
810    #[test]
811    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
812        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
813        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
814        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
815        assert_eq!(h.service, "s3");
816        assert_eq!(h.region, "us-east-1");
817        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
818    }
819
820    #[test]
821    fn parse_routing_host_aws_s3_dash_separated() {
822        // Older dash-separated form still served by AWS.
823        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
824        assert_eq!(h.service, "s3");
825        assert_eq!(h.region, "us-west-2");
826        assert!(h.bucket.is_none());
827
828        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
829        assert_eq!(h.service, "s3");
830        assert_eq!(h.region, "us-west-2");
831        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
832    }
833
834    #[test]
835    fn parse_routing_host_rejects_plain_localhost() {
836        assert!(parse_routing_host("localhost:4566").is_none());
837        assert!(parse_routing_host("127.0.0.1:4566").is_none());
838    }
839
840    #[test]
841    fn parse_routing_host_rejects_unknown_suffix() {
842        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
843        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
844    }
845
846    #[test]
847    fn parse_routing_host_empty_and_malformed_rejected() {
848        assert!(parse_routing_host("").is_none());
849        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
850        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
851        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
852        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
853        assert!(parse_routing_host(".amazonaws.com").is_none());
854        assert!(parse_routing_host("amazonaws.com").is_none());
855    }
856
857    #[test]
858    fn detect_service_via_host_for_rest_service() {
859        let mut headers = HeaderMap::new();
860        headers.insert(
861            "host",
862            "s3.us-east-1.localhost.localstack.cloud:4566"
863                .parse()
864                .unwrap(),
865        );
866        let query = HashMap::new();
867        let body = Bytes::new();
868        let detected = detect_service(&headers, &query, &body).unwrap();
869        assert_eq!(detected.service, "s3");
870        assert_eq!(detected.protocol, AwsProtocol::Rest);
871    }
872
873    #[test]
874    fn detect_service_via_host_for_rest_json_service() {
875        let mut headers = HeaderMap::new();
876        headers.insert(
877            "host",
878            "lambda.us-east-1.localhost.localstack.cloud:4566"
879                .parse()
880                .unwrap(),
881        );
882        let query = HashMap::new();
883        let body = Bytes::new();
884        let detected = detect_service(&headers, &query, &body).unwrap();
885        assert_eq!(detected.service, "lambda");
886        assert_eq!(detected.protocol, AwsProtocol::RestJson);
887    }
888
889    #[test]
890    fn detect_service_via_host_plus_query_action() {
891        let mut headers = HeaderMap::new();
892        headers.insert(
893            "host",
894            "sqs.us-east-1.localhost.localstack.cloud:4566"
895                .parse()
896                .unwrap(),
897        );
898        let mut query = HashMap::new();
899        query.insert("Action".to_string(), "ListQueues".to_string());
900        let body = Bytes::new();
901        let detected = detect_service(&headers, &query, &body).unwrap();
902        assert_eq!(detected.service, "sqs");
903        assert_eq!(detected.action, "ListQueues");
904        assert_eq!(detected.protocol, AwsProtocol::Query);
905    }
906
907    #[test]
908    fn detect_service_sigv4_wins_over_host() {
909        let mut headers = HeaderMap::new();
910        headers.insert(
911            "authorization",
912            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
913             SignedHeaders=host, Signature=abc"
914                .parse()
915                .unwrap(),
916        );
917        headers.insert(
918            "host",
919            "lambda.us-east-1.localhost.localstack.cloud:4566"
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        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
927        assert_eq!(detected.service, "s3");
928        assert_eq!(detected.protocol, AwsProtocol::Rest);
929    }
930
931    #[test]
932    fn detect_service_host_for_virtual_hosted_s3() {
933        let mut headers = HeaderMap::new();
934        headers.insert(
935            "host",
936            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
937                .parse()
938                .unwrap(),
939        );
940        let query = HashMap::new();
941        let body = Bytes::new();
942        let detected = detect_service(&headers, &query, &body).unwrap();
943        assert_eq!(detected.service, "s3");
944        assert_eq!(detected.protocol, AwsProtocol::Rest);
945    }
946}