1use bytes::Bytes;
2use http::HeaderMap;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum AwsProtocol {
8 Query,
11 Json,
14 Rest,
17 RestJson,
20}
21
22const REST_XML_SERVICES: &[&str] = &["s3"];
24
25const REST_JSON_SERVICES: &[&str] = &["lambda", "ses", "apigateway", "bedrock", "scheduler"];
27
28#[derive(Debug)]
30pub struct DetectedRequest {
31 pub service: String,
32 pub action: String,
33 pub protocol: AwsProtocol,
34}
35
36pub fn detect_service(
38 headers: &HeaderMap,
39 query_params: &HashMap<String, String>,
40 body: &Bytes,
41) -> Option<DetectedRequest> {
42 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 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 {
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 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(), protocol,
86 });
87 }
88 }
89
90 if let Some(credential) = query_params.get("X-Amz-Credential") {
92 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct RoutingHost {
146 pub service: String,
147 pub region: String,
148 pub bucket: Option<String>,
150}
151
152const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
153const AWS_SUFFIX: &str = ".amazonaws.com";
154
155pub 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
176pub 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
208fn 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 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 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 2 => Some(RoutingHost {
268 service: labels[0].to_string(),
269 region: labels[1].to_string(),
270 bucket: None,
271 }),
272 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
285fn 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
313fn 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
324fn 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 "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
392fn 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
399pub 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 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 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 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 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 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}