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("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
315fn 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
326fn 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 "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
394fn 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
401pub 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 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 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 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 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}