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("AWSStepFunctions") => "states",
302        s if s.starts_with("AWSOrganizationsV") => "organizations",
303        _ => return None,
304    };
305
306    Some(DetectedRequest {
307        service: service.to_string(),
308        action: action.to_string(),
309        protocol: AwsProtocol::Json,
310    })
311}
312
313/// Returns the REST protocol variant for a service, or None if not a REST service.
314fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
315    if REST_XML_SERVICES.contains(&service) {
316        Some(AwsProtocol::Rest)
317    } else if REST_JSON_SERVICES.contains(&service) {
318        Some(AwsProtocol::RestJson)
319    } else {
320        None
321    }
322}
323
324/// Infer service from the action name when no SigV4 auth is present.
325/// Some AWS operations (e.g., AssumeRoleWithSAML, AssumeRoleWithWebIdentity)
326/// do not require authentication and won't have an Authorization header.
327fn infer_service_from_action(action: &str) -> Option<String> {
328    match action {
329        "AssumeRole"
330        | "AssumeRoleWithSAML"
331        | "AssumeRoleWithWebIdentity"
332        | "GetCallerIdentity"
333        | "GetSessionToken"
334        | "GetFederationToken"
335        | "GetAccessKeyInfo"
336        | "DecodeAuthorizationMessage" => Some("sts".to_string()),
337        "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
338        | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
339        | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
340        | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
341        // SES v1 (Query protocol)
342        "VerifyEmailIdentity"
343        | "VerifyDomainIdentity"
344        | "VerifyDomainDkim"
345        | "ListIdentities"
346        | "GetIdentityVerificationAttributes"
347        | "GetIdentityDkimAttributes"
348        | "DeleteIdentity"
349        | "SetIdentityDkimEnabled"
350        | "SetIdentityNotificationTopic"
351        | "SetIdentityFeedbackForwardingEnabled"
352        | "GetIdentityNotificationAttributes"
353        | "GetIdentityMailFromDomainAttributes"
354        | "SetIdentityMailFromDomain"
355        | "SendEmail"
356        | "SendRawEmail"
357        | "SendTemplatedEmail"
358        | "SendBulkTemplatedEmail"
359        | "CreateTemplate"
360        | "GetTemplate"
361        | "ListTemplates"
362        | "DeleteTemplate"
363        | "UpdateTemplate"
364        | "CreateConfigurationSet"
365        | "DeleteConfigurationSet"
366        | "DescribeConfigurationSet"
367        | "ListConfigurationSets"
368        | "CreateConfigurationSetEventDestination"
369        | "UpdateConfigurationSetEventDestination"
370        | "DeleteConfigurationSetEventDestination"
371        | "GetSendQuota"
372        | "GetSendStatistics"
373        | "GetAccountSendingEnabled"
374        | "CreateReceiptRuleSet"
375        | "DeleteReceiptRuleSet"
376        | "DescribeReceiptRuleSet"
377        | "ListReceiptRuleSets"
378        | "CloneReceiptRuleSet"
379        | "SetActiveReceiptRuleSet"
380        | "ReorderReceiptRuleSet"
381        | "CreateReceiptRule"
382        | "DeleteReceiptRule"
383        | "DescribeReceiptRule"
384        | "UpdateReceiptRule"
385        | "CreateReceiptFilter"
386        | "DeleteReceiptFilter"
387        | "ListReceiptFilters" => Some("ses".to_string()),
388        _ => None,
389    }
390}
391
392/// Extract service name from the SigV4 Authorization header credential scope.
393fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
394    let auth = headers.get("authorization")?.to_str().ok()?;
395    let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
396    Some(info.service)
397}
398
399/// Parse form-encoded body into key-value pairs.
400pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
401    decode_form_urlencoded(body)
402}
403
404fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
405    let s = std::str::from_utf8(input).unwrap_or("");
406    let mut result = HashMap::new();
407    for pair in s.split('&') {
408        if pair.is_empty() {
409            continue;
410        }
411        let (key, value) = match pair.find('=') {
412            Some(pos) => (&pair[..pos], &pair[pos + 1..]),
413            None => (pair, ""),
414        };
415        result.insert(url_decode(key), url_decode(value));
416    }
417    result
418}
419
420fn url_decode(input: &str) -> String {
421    let mut result = String::with_capacity(input.len());
422    let mut bytes = input.bytes();
423    while let Some(b) = bytes.next() {
424        match b {
425            b'+' => result.push(' '),
426            b'%' => {
427                let high = bytes.next().and_then(from_hex);
428                let low = bytes.next().and_then(from_hex);
429                if let (Some(h), Some(l)) = (high, low) {
430                    result.push((h << 4 | l) as char);
431                }
432            }
433            _ => result.push(b as char),
434        }
435    }
436    result
437}
438
439fn from_hex(b: u8) -> Option<u8> {
440    match b {
441        b'0'..=b'9' => Some(b - b'0'),
442        b'a'..=b'f' => Some(b - b'a' + 10),
443        b'A'..=b'F' => Some(b - b'A' + 10),
444        _ => None,
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn parse_amz_target_events() {
454        let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
455        assert_eq!(result.service, "events");
456        assert_eq!(result.action, "PutEvents");
457        assert_eq!(result.protocol, AwsProtocol::Json);
458    }
459
460    #[test]
461    fn parse_amz_target_ssm() {
462        let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
463        assert_eq!(result.service, "ssm");
464        assert_eq!(result.action, "GetParameter");
465    }
466
467    #[test]
468    fn parse_amz_target_kinesis() {
469        let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
470        assert_eq!(result.service, "kinesis");
471        assert_eq!(result.action, "ListStreams");
472        assert_eq!(result.protocol, AwsProtocol::Json);
473    }
474
475    #[test]
476    fn parse_query_body_basic() {
477        let body = Bytes::from(
478            "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
479        );
480        let params = parse_query_body(&body);
481        assert_eq!(params.get("Action").unwrap(), "SendMessage");
482        assert_eq!(params.get("MessageBody").unwrap(), "hello");
483    }
484
485    #[test]
486    fn parse_query_body_empty_returns_empty_map() {
487        let body = Bytes::from("");
488        let params = parse_query_body(&body);
489        assert!(params.is_empty());
490    }
491
492    #[test]
493    fn parse_query_body_duplicate_keys_last_wins() {
494        let body = Bytes::from("key=a&key=b");
495        let params = parse_query_body(&body);
496        assert_eq!(params.get("key").unwrap(), "b");
497    }
498
499    #[test]
500    fn parse_query_body_single_key() {
501        let body = Bytes::from("key=value");
502        let params = parse_query_body(&body);
503        assert_eq!(params.get("key").unwrap(), "value");
504    }
505
506    #[test]
507    fn parse_amz_target_rds() {
508        let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters");
509        // Not ECS — just verify doesn't panic on unknown prefixes
510        assert!(result.is_some() || result.is_none());
511    }
512
513    #[test]
514    fn parse_amz_target_invalid_returns_none() {
515        assert!(parse_amz_target("NoDotHere").is_none());
516        assert!(parse_amz_target("").is_none());
517    }
518
519    #[test]
520    fn parse_amz_target_various_prefixes() {
521        assert_eq!(
522            parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
523            "sqs"
524        );
525        assert_eq!(
526            parse_amz_target("AmazonSNS.Publish").unwrap().service,
527            "sns"
528        );
529        assert_eq!(
530            parse_amz_target("DynamoDB_20120810.GetItem")
531                .unwrap()
532                .service,
533            "dynamodb"
534        );
535        assert_eq!(
536            parse_amz_target("Logs_20140328.PutLogEvents")
537                .unwrap()
538                .service,
539            "logs"
540        );
541        assert_eq!(
542            parse_amz_target("secretsmanager.GetSecretValue")
543                .unwrap()
544                .service,
545            "secretsmanager"
546        );
547        assert_eq!(
548            parse_amz_target("TrentService.Encrypt").unwrap().service,
549            "kms"
550        );
551        assert_eq!(
552            parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
553                .unwrap()
554                .service,
555            "cognito-idp"
556        );
557        assert_eq!(
558            parse_amz_target("AWSStepFunctions.StartExecution")
559                .unwrap()
560                .service,
561            "states"
562        );
563        assert_eq!(
564            parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
565                .unwrap()
566                .service,
567            "organizations"
568        );
569        assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
570    }
571
572    #[test]
573    fn infer_service_from_action_maps_sts() {
574        assert_eq!(
575            infer_service_from_action("AssumeRole").as_deref(),
576            Some("sts")
577        );
578        assert_eq!(
579            infer_service_from_action("GetCallerIdentity").as_deref(),
580            Some("sts")
581        );
582    }
583
584    #[test]
585    fn infer_service_from_action_maps_iam() {
586        assert_eq!(
587            infer_service_from_action("CreateUser").as_deref(),
588            Some("iam")
589        );
590        assert_eq!(
591            infer_service_from_action("ListRoles").as_deref(),
592            Some("iam")
593        );
594    }
595
596    #[test]
597    fn infer_service_from_action_maps_ses() {
598        assert_eq!(
599            infer_service_from_action("SendEmail").as_deref(),
600            Some("ses")
601        );
602        assert_eq!(
603            infer_service_from_action("ListIdentities").as_deref(),
604            Some("ses")
605        );
606    }
607
608    #[test]
609    fn infer_service_from_action_unknown_returns_none() {
610        assert!(infer_service_from_action("NotARealAction").is_none());
611    }
612
613    #[test]
614    fn rest_protocol_for_returns_none_for_non_rest_service() {
615        assert!(rest_protocol_for("sqs").is_none());
616    }
617
618    #[test]
619    fn url_decode_handles_percent_and_plus() {
620        assert_eq!(url_decode("hello+world"), "hello world");
621        assert_eq!(url_decode("hello%20world"), "hello world");
622        assert_eq!(url_decode("100%25"), "100%");
623    }
624
625    #[test]
626    fn url_decode_ignores_malformed_percent() {
627        assert_eq!(url_decode("%ZZ"), "");
628    }
629
630    #[test]
631    fn from_hex_valid_digits() {
632        assert_eq!(from_hex(b'0'), Some(0));
633        assert_eq!(from_hex(b'9'), Some(9));
634        assert_eq!(from_hex(b'a'), Some(10));
635        assert_eq!(from_hex(b'F'), Some(15));
636    }
637
638    #[test]
639    fn from_hex_invalid_returns_none() {
640        assert!(from_hex(b'g').is_none());
641        assert!(from_hex(b' ').is_none());
642    }
643
644    #[test]
645    fn detect_service_via_amz_target() {
646        let mut headers = HeaderMap::new();
647        headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
648        let query = HashMap::new();
649        let body = Bytes::new();
650        let detected = detect_service(&headers, &query, &body).unwrap();
651        assert_eq!(detected.service, "ssm");
652        assert_eq!(detected.action, "GetParameter");
653    }
654
655    #[test]
656    fn detect_service_via_query_action_with_inferred_service() {
657        let headers = HeaderMap::new();
658        let mut query = HashMap::new();
659        query.insert("Action".to_string(), "AssumeRole".to_string());
660        let body = Bytes::new();
661        let detected = detect_service(&headers, &query, &body).unwrap();
662        assert_eq!(detected.service, "sts");
663        assert_eq!(detected.action, "AssumeRole");
664        assert_eq!(detected.protocol, AwsProtocol::Query);
665    }
666
667    #[test]
668    fn detect_service_via_form_body() {
669        let headers = HeaderMap::new();
670        let query = HashMap::new();
671        let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
672        let detected = detect_service(&headers, &query, &body).unwrap();
673        assert_eq!(detected.service, "ses");
674        assert_eq!(detected.action, "SendEmail");
675    }
676
677    #[test]
678    fn detect_service_via_sigv2_presigned() {
679        let headers = HeaderMap::new();
680        let mut query = HashMap::new();
681        query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
682        query.insert("Signature".to_string(), "sig".to_string());
683        query.insert("Expires".to_string(), "1234567890".to_string());
684        let body = Bytes::new();
685        let detected = detect_service(&headers, &query, &body).unwrap();
686        assert_eq!(detected.service, "s3");
687        assert_eq!(detected.protocol, AwsProtocol::Rest);
688    }
689
690    #[test]
691    fn detect_service_via_sigv4_presigned_credential() {
692        let headers = HeaderMap::new();
693        let mut query = HashMap::new();
694        query.insert(
695            "X-Amz-Credential".to_string(),
696            "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
697        );
698        let body = Bytes::new();
699        let detected = detect_service(&headers, &query, &body).unwrap();
700        assert_eq!(detected.service, "s3");
701        assert_eq!(detected.protocol, AwsProtocol::Rest);
702    }
703
704    #[test]
705    fn detect_service_unknown_returns_none() {
706        let headers = HeaderMap::new();
707        let query = HashMap::new();
708        let body = Bytes::new();
709        assert!(detect_service(&headers, &query, &body).is_none());
710    }
711
712    #[test]
713    fn parse_routing_host_localstack_basic() {
714        let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
715        assert_eq!(h.service, "sqs");
716        assert_eq!(h.region, "us-east-1");
717        assert!(h.bucket.is_none());
718    }
719
720    #[test]
721    fn parse_routing_host_localstack_with_port() {
722        let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
723        assert_eq!(h.service, "lambda");
724        assert_eq!(h.region, "eu-west-1");
725        assert!(h.bucket.is_none());
726    }
727
728    #[test]
729    fn parse_routing_host_case_insensitive() {
730        let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
731        assert_eq!(h.service, "sqs");
732        assert_eq!(h.region, "us-east-1");
733
734        let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
735        assert_eq!(h.service, "lambda");
736        assert_eq!(h.region, "us-east-1");
737    }
738
739    #[test]
740    fn parse_routing_host_localstack_s3_virtual_hosted() {
741        let h =
742            parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
743        assert_eq!(h.service, "s3");
744        assert_eq!(h.region, "us-east-1");
745        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
746    }
747
748    #[test]
749    fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
750        let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
751        assert_eq!(h.service, "s3");
752        assert_eq!(h.region, "us-east-1");
753        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
754    }
755
756    #[test]
757    fn parse_routing_host_aws_service_region() {
758        let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
759        assert_eq!(h.service, "sqs");
760        assert_eq!(h.region, "us-east-1");
761        assert!(h.bucket.is_none());
762
763        let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
764        assert_eq!(h.service, "dynamodb");
765        assert_eq!(h.region, "eu-west-2");
766    }
767
768    #[test]
769    fn parse_routing_host_aws_s3_path_style_modern() {
770        let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
771        assert_eq!(h.service, "s3");
772        assert_eq!(h.region, "us-east-1");
773        assert!(h.bucket.is_none());
774    }
775
776    #[test]
777    fn parse_routing_host_aws_s3_virtual_hosted_modern() {
778        let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
779        assert_eq!(h.service, "s3");
780        assert_eq!(h.region, "us-east-1");
781        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
782    }
783
784    #[test]
785    fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
786        let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
787        assert_eq!(h.service, "s3");
788        assert_eq!(h.region, "us-east-1");
789        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
790    }
791
792    #[test]
793    fn parse_routing_host_aws_s3_legacy_global() {
794        // `s3.amazonaws.com` (no region) is the legacy S3 global endpoint —
795        // AWS treats it as us-east-1 for both path-style and virtual-hosted.
796        let h = parse_routing_host("s3.amazonaws.com").unwrap();
797        assert_eq!(h.service, "s3");
798        assert_eq!(h.region, "us-east-1");
799        assert!(h.bucket.is_none());
800
801        let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
802        assert_eq!(h.service, "s3");
803        assert_eq!(h.region, "us-east-1");
804        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
805    }
806
807    #[test]
808    fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
809        // AWS allows buckets with dots (e.g. `a.b.c`) and still serves them
810        // via the legacy `<bucket>.s3.amazonaws.com` global endpoint.
811        let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
812        assert_eq!(h.service, "s3");
813        assert_eq!(h.region, "us-east-1");
814        assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
815    }
816
817    #[test]
818    fn parse_routing_host_aws_s3_dash_separated() {
819        // Older dash-separated form still served by AWS.
820        let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
821        assert_eq!(h.service, "s3");
822        assert_eq!(h.region, "us-west-2");
823        assert!(h.bucket.is_none());
824
825        let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
826        assert_eq!(h.service, "s3");
827        assert_eq!(h.region, "us-west-2");
828        assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
829    }
830
831    #[test]
832    fn parse_routing_host_rejects_plain_localhost() {
833        assert!(parse_routing_host("localhost:4566").is_none());
834        assert!(parse_routing_host("127.0.0.1:4566").is_none());
835    }
836
837    #[test]
838    fn parse_routing_host_rejects_unknown_suffix() {
839        assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
840        assert!(parse_routing_host("s3.us-east-1.aws").is_none());
841    }
842
843    #[test]
844    fn parse_routing_host_empty_and_malformed_rejected() {
845        assert!(parse_routing_host("").is_none());
846        assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
847        assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
848        assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
849        assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
850        assert!(parse_routing_host(".amazonaws.com").is_none());
851        assert!(parse_routing_host("amazonaws.com").is_none());
852    }
853
854    #[test]
855    fn detect_service_via_host_for_rest_service() {
856        let mut headers = HeaderMap::new();
857        headers.insert(
858            "host",
859            "s3.us-east-1.localhost.localstack.cloud:4566"
860                .parse()
861                .unwrap(),
862        );
863        let query = HashMap::new();
864        let body = Bytes::new();
865        let detected = detect_service(&headers, &query, &body).unwrap();
866        assert_eq!(detected.service, "s3");
867        assert_eq!(detected.protocol, AwsProtocol::Rest);
868    }
869
870    #[test]
871    fn detect_service_via_host_for_rest_json_service() {
872        let mut headers = HeaderMap::new();
873        headers.insert(
874            "host",
875            "lambda.us-east-1.localhost.localstack.cloud:4566"
876                .parse()
877                .unwrap(),
878        );
879        let query = HashMap::new();
880        let body = Bytes::new();
881        let detected = detect_service(&headers, &query, &body).unwrap();
882        assert_eq!(detected.service, "lambda");
883        assert_eq!(detected.protocol, AwsProtocol::RestJson);
884    }
885
886    #[test]
887    fn detect_service_via_host_plus_query_action() {
888        let mut headers = HeaderMap::new();
889        headers.insert(
890            "host",
891            "sqs.us-east-1.localhost.localstack.cloud:4566"
892                .parse()
893                .unwrap(),
894        );
895        let mut query = HashMap::new();
896        query.insert("Action".to_string(), "ListQueues".to_string());
897        let body = Bytes::new();
898        let detected = detect_service(&headers, &query, &body).unwrap();
899        assert_eq!(detected.service, "sqs");
900        assert_eq!(detected.action, "ListQueues");
901        assert_eq!(detected.protocol, AwsProtocol::Query);
902    }
903
904    #[test]
905    fn detect_service_sigv4_wins_over_host() {
906        let mut headers = HeaderMap::new();
907        headers.insert(
908            "authorization",
909            "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
910             SignedHeaders=host, Signature=abc"
911                .parse()
912                .unwrap(),
913        );
914        headers.insert(
915            "host",
916            "lambda.us-east-1.localhost.localstack.cloud:4566"
917                .parse()
918                .unwrap(),
919        );
920        let query = HashMap::new();
921        let body = Bytes::new();
922        let detected = detect_service(&headers, &query, &body).unwrap();
923        // SigV4 credential scope says s3; Host header says lambda. SigV4 wins.
924        assert_eq!(detected.service, "s3");
925        assert_eq!(detected.protocol, AwsProtocol::Rest);
926    }
927
928    #[test]
929    fn detect_service_host_for_virtual_hosted_s3() {
930        let mut headers = HeaderMap::new();
931        headers.insert(
932            "host",
933            "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
934                .parse()
935                .unwrap(),
936        );
937        let query = HashMap::new();
938        let body = Bytes::new();
939        let detected = detect_service(&headers, &query, &body).unwrap();
940        assert_eq!(detected.service, "s3");
941        assert_eq!(detected.protocol, AwsProtocol::Rest);
942    }
943}