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", "cloudfront", "route53"];
24
25const REST_JSON_SERVICES: &[&str] = &[
27 "lambda",
28 "ses",
29 "apigateway",
30 "bedrock",
31 "bedrock-agent",
32 "bedrock-agent-runtime",
33 "scheduler",
34];
35
36#[derive(Debug, Clone)]
38pub struct DetectedRequest {
39 pub service: String,
40 pub action: String,
41 pub protocol: AwsProtocol,
42}
43
44pub fn detect_service_headers_only(
51 headers: &HeaderMap,
52 query_params: &HashMap<String, String>,
53) -> Option<DetectedRequest> {
54 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
56 return parse_amz_target(target);
57 }
58 if let Some(action) = query_params.get("Action") {
59 let service = extract_service_from_auth(headers)
60 .or_else(|| infer_service_from_action(action))
61 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
62 if let Some(service) = service {
63 return Some(DetectedRequest {
64 service,
65 action: action.clone(),
66 protocol: AwsProtocol::Query,
67 });
68 }
69 }
70 if let Some(service) = extract_service_from_auth(headers) {
71 if let Some(protocol) = rest_protocol_for(&service) {
72 return Some(DetectedRequest {
73 service,
74 action: String::new(),
75 protocol,
76 });
77 }
78 }
79 if let Some(credential) = query_params.get("X-Amz-Credential") {
80 let parts: Vec<&str> = credential.split('/').collect();
81 if parts.len() >= 4 {
82 let service = normalize_service_name(parts[3]).to_string();
83 if let Some(protocol) = rest_protocol_for(&service) {
84 return Some(DetectedRequest {
85 service,
86 action: String::new(),
87 protocol,
88 });
89 }
90 }
91 }
92 if query_params.contains_key("AWSAccessKeyId")
93 && query_params.contains_key("Signature")
94 && query_params.contains_key("Expires")
95 {
96 return Some(DetectedRequest {
97 service: "s3".to_string(),
98 action: String::new(),
99 protocol: AwsProtocol::Rest,
100 });
101 }
102 if let Some(host_info) = parse_routing_host_from_headers(headers) {
103 if let Some(protocol) = rest_protocol_for(&host_info.service) {
104 return Some(DetectedRequest {
105 service: host_info.service,
106 action: String::new(),
107 protocol,
108 });
109 }
110 }
111 None
112}
113
114pub fn detect_service(
116 headers: &HeaderMap,
117 query_params: &HashMap<String, String>,
118 body: &Bytes,
119) -> Option<DetectedRequest> {
120 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
122 return parse_amz_target(target);
123 }
124
125 if let Some(action) = query_params.get("Action") {
127 let service = extract_service_from_auth(headers)
128 .or_else(|| infer_service_from_action(action))
129 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
130 if let Some(service) = service {
131 return Some(DetectedRequest {
132 service,
133 action: action.clone(),
134 protocol: AwsProtocol::Query,
135 });
136 }
137 }
138
139 {
141 let form_params = decode_form_urlencoded(body);
142
143 if let Some(action) = form_params.get("Action") {
144 let service = extract_service_from_auth(headers)
145 .or_else(|| infer_service_from_action(action))
146 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
147 if let Some(service) = service {
148 return Some(DetectedRequest {
149 service,
150 action: action.clone(),
151 protocol: AwsProtocol::Query,
152 });
153 }
154 }
155 }
156
157 if let Some(service) = extract_service_from_auth(headers) {
159 if let Some(protocol) = rest_protocol_for(&service) {
160 return Some(DetectedRequest {
161 service,
162 action: String::new(), protocol,
164 });
165 }
166 }
167
168 if let Some(credential) = query_params.get("X-Amz-Credential") {
170 let parts: Vec<&str> = credential.split('/').collect();
172 if parts.len() >= 4 {
173 let service = normalize_service_name(parts[3]).to_string();
174 if let Some(protocol) = rest_protocol_for(&service) {
175 return Some(DetectedRequest {
176 service,
177 action: String::new(),
178 protocol,
179 });
180 }
181 }
182 }
183
184 if query_params.contains_key("AWSAccessKeyId")
188 && query_params.contains_key("Signature")
189 && query_params.contains_key("Expires")
190 {
191 return Some(DetectedRequest {
192 service: "s3".to_string(),
193 action: String::new(),
194 protocol: AwsProtocol::Rest,
195 });
196 }
197
198 if let Some(host_info) = parse_routing_host_from_headers(headers) {
202 if let Some(protocol) = rest_protocol_for(&host_info.service) {
203 return Some(DetectedRequest {
204 service: host_info.service,
205 action: String::new(),
206 protocol,
207 });
208 }
209 }
210
211 None
212}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct RoutingHost {
224 pub service: String,
225 pub region: String,
226 pub bucket: Option<String>,
228}
229
230const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
231const AWS_SUFFIX: &str = ".amazonaws.com";
232
233pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
237 let hostname = host.split(':').next()?;
238 if hostname.is_empty() {
239 return None;
240 }
241 let hostname = hostname.to_ascii_lowercase();
242 if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
243 return parse_localstack_prefix(prefix);
244 }
245 if hostname == "amazonaws.com" {
246 return None;
247 }
248 if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
249 return parse_aws_prefix(prefix);
250 }
251 None
252}
253
254pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
256 let host = headers.get("host")?.to_str().ok()?;
257 parse_routing_host(host)
258}
259
260fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
261 if prefix.is_empty() {
262 return None;
263 }
264 let labels: Vec<&str> = prefix.split('.').collect();
265 if labels.iter().any(|l| l.is_empty()) {
266 return None;
267 }
268 match labels.len() {
269 2 => Some(RoutingHost {
270 service: labels[0].to_string(),
271 region: labels[1].to_string(),
272 bucket: None,
273 }),
274 n if n >= 3 && labels[n - 2] == "s3" => {
275 let bucket = labels[..n - 2].join(".");
276 Some(RoutingHost {
277 service: "s3".to_string(),
278 region: labels[n - 1].to_string(),
279 bucket: Some(bucket),
280 })
281 }
282 n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
283 let bucket = labels[..n - 2].join(".");
284 Some(RoutingHost {
285 service: "s3".to_string(),
286 region: labels[n - 1].to_string(),
287 bucket: Some(bucket),
288 })
289 }
290 n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
291 service: "s3".to_string(),
292 region: labels[n - 1].to_string(),
293 bucket: None,
294 }),
295 _ => None,
296 }
297}
298
299fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
311 if prefix.is_empty() {
312 return None;
313 }
314 let labels: Vec<&str> = prefix.split('.').collect();
315 if labels.iter().any(|l| l.is_empty()) {
316 return None;
317 }
318 let last = *labels.last()?;
319
320 if let Some(region) = last.strip_prefix("s3-") {
323 if !region.is_empty() {
324 let bucket = if labels.len() >= 2 {
325 Some(labels[..labels.len() - 1].join("."))
326 } else {
327 None
328 };
329 return Some(RoutingHost {
330 service: "s3".to_string(),
331 region: region.to_string(),
332 bucket,
333 });
334 }
335 }
336
337 if last == "s3" {
341 if labels.len() == 1 {
342 return Some(RoutingHost {
343 service: "s3".to_string(),
344 region: "us-east-1".to_string(),
345 bucket: None,
346 });
347 }
348 return Some(RoutingHost {
349 service: "s3".to_string(),
350 region: "us-east-1".to_string(),
351 bucket: Some(labels[..labels.len() - 1].join(".")),
352 });
353 }
354
355 if last == "s3-accesspoint" {
358 if labels.len() == 2 {
359 return Some(RoutingHost {
360 service: "s3".to_string(),
361 region: labels[0].to_string(),
362 bucket: None,
363 });
364 }
365 let bucket = labels[..labels.len() - 2].join(".");
366 return Some(RoutingHost {
367 service: "s3".to_string(),
368 region: labels[labels.len() - 1].to_string(),
369 bucket: Some(bucket),
370 });
371 }
372
373 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
376 return Some(RoutingHost {
377 service: "s3".to_string(),
378 region: last.to_string(),
379 bucket: None,
380 });
381 }
382
383 match labels.len() {
384 2 => Some(RoutingHost {
387 service: labels[0].to_string(),
388 region: labels[1].to_string(),
389 bucket: None,
390 }),
391 n if n >= 3 && labels[n - 2] == "s3" => {
393 let bucket = labels[..n - 2].join(".");
394 Some(RoutingHost {
395 service: "s3".to_string(),
396 region: labels[n - 1].to_string(),
397 bucket: Some(bucket),
398 })
399 }
400 _ => None,
401 }
402}
403
404fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
407 let (prefix, action) = target.rsplit_once('.')?;
408
409 let service = match prefix {
410 "AWSEvents" => "events",
411 "AmazonSSM" => "ssm",
412 "AmazonSQS" => "sqs",
413 "AmazonSNS" => "sns",
414 "DynamoDB_20120810" => "dynamodb",
415 "DynamoDBStreams_20120810" => "dynamodbstreams",
416 "Logs_20140328" => "logs",
417 s if s.starts_with("secretsmanager") => "secretsmanager",
418 s if s.starts_with("TrentService") => "kms",
419 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
420 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
421 s if s.starts_with("Kinesis_20131202") => "kinesis",
422 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
423 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
424 s if s.starts_with("AWSStepFunctions") => "states",
425 s if s.starts_with("AWSOrganizationsV") => "organizations",
426 "CertificateManager" => "acm",
427 "AnyScaleFrontendService" => "application-autoscaling",
428 "AWSWAF_20190729" => "wafv2",
431 "AmazonAthena" => "athena",
432 s if s.starts_with("Firehose_") => "firehose",
433 "AWSGlue" => "glue",
434 _ => return None,
435 };
436
437 Some(DetectedRequest {
438 service: service.to_string(),
439 action: action.to_string(),
440 protocol: AwsProtocol::Json,
441 })
442}
443
444fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
446 if REST_XML_SERVICES.contains(&service) {
447 Some(AwsProtocol::Rest)
448 } else if REST_JSON_SERVICES.contains(&service) {
449 Some(AwsProtocol::RestJson)
450 } else {
451 None
452 }
453}
454
455fn infer_service_from_action(action: &str) -> Option<String> {
459 match action {
460 "AssumeRole"
461 | "AssumeRoleWithSAML"
462 | "AssumeRoleWithWebIdentity"
463 | "GetCallerIdentity"
464 | "GetSessionToken"
465 | "GetFederationToken"
466 | "GetAccessKeyInfo"
467 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
468 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
469 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
470 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
471 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
472 "VerifyEmailIdentity"
474 | "VerifyDomainIdentity"
475 | "VerifyDomainDkim"
476 | "ListIdentities"
477 | "GetIdentityVerificationAttributes"
478 | "GetIdentityDkimAttributes"
479 | "DeleteIdentity"
480 | "SetIdentityDkimEnabled"
481 | "SetIdentityNotificationTopic"
482 | "SetIdentityFeedbackForwardingEnabled"
483 | "GetIdentityNotificationAttributes"
484 | "GetIdentityMailFromDomainAttributes"
485 | "SetIdentityMailFromDomain"
486 | "SendEmail"
487 | "SendRawEmail"
488 | "SendTemplatedEmail"
489 | "SendBulkTemplatedEmail"
490 | "CreateTemplate"
491 | "GetTemplate"
492 | "ListTemplates"
493 | "DeleteTemplate"
494 | "UpdateTemplate"
495 | "CreateConfigurationSet"
496 | "DeleteConfigurationSet"
497 | "DescribeConfigurationSet"
498 | "ListConfigurationSets"
499 | "CreateConfigurationSetEventDestination"
500 | "UpdateConfigurationSetEventDestination"
501 | "DeleteConfigurationSetEventDestination"
502 | "GetSendQuota"
503 | "GetSendStatistics"
504 | "GetAccountSendingEnabled"
505 | "CreateReceiptRuleSet"
506 | "DeleteReceiptRuleSet"
507 | "DescribeReceiptRuleSet"
508 | "ListReceiptRuleSets"
509 | "CloneReceiptRuleSet"
510 | "SetActiveReceiptRuleSet"
511 | "ReorderReceiptRuleSet"
512 | "CreateReceiptRule"
513 | "DeleteReceiptRule"
514 | "DescribeReceiptRule"
515 | "UpdateReceiptRule"
516 | "CreateReceiptFilter"
517 | "DeleteReceiptFilter"
518 | "ListReceiptFilters" => Some("ses".to_string()),
519 _ => None,
520 }
521}
522
523fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
525 let auth = headers.get("authorization")?.to_str().ok()?;
526 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
527 Some(normalize_service_name(&info.service).to_string())
528}
529
530fn normalize_service_name(service: &str) -> &str {
542 match service {
543 "bedrock-runtime" => "bedrock",
544 other => other,
545 }
546}
547
548pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
550 decode_form_urlencoded(body)
551}
552
553fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
554 let s = std::str::from_utf8(input).unwrap_or("");
555 let mut result = HashMap::new();
556 for pair in s.split('&') {
557 if pair.is_empty() {
558 continue;
559 }
560 let (key, value) = match pair.find('=') {
561 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
562 None => (pair, ""),
563 };
564 result.insert(url_decode(key), url_decode(value));
565 }
566 result
567}
568
569fn url_decode(input: &str) -> String {
570 let mut result = String::with_capacity(input.len());
571 let mut bytes = input.bytes();
572 while let Some(b) = bytes.next() {
573 match b {
574 b'+' => result.push(' '),
575 b'%' => {
576 let high = bytes.next().and_then(from_hex);
577 let low = bytes.next().and_then(from_hex);
578 if let (Some(h), Some(l)) = (high, low) {
579 result.push((h << 4 | l) as char);
580 }
581 }
582 _ => result.push(b as char),
583 }
584 }
585 result
586}
587
588fn from_hex(b: u8) -> Option<u8> {
589 match b {
590 b'0'..=b'9' => Some(b - b'0'),
591 b'a'..=b'f' => Some(b - b'a' + 10),
592 b'A'..=b'F' => Some(b - b'A' + 10),
593 _ => None,
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 #[test]
602 fn parse_amz_target_events() {
603 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
604 assert_eq!(result.service, "events");
605 assert_eq!(result.action, "PutEvents");
606 assert_eq!(result.protocol, AwsProtocol::Json);
607 }
608
609 #[test]
610 fn parse_amz_target_ssm() {
611 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
612 assert_eq!(result.service, "ssm");
613 assert_eq!(result.action, "GetParameter");
614 }
615
616 #[test]
617 fn parse_amz_target_kinesis() {
618 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
619 assert_eq!(result.service, "kinesis");
620 assert_eq!(result.action, "ListStreams");
621 assert_eq!(result.protocol, AwsProtocol::Json);
622 }
623
624 #[test]
625 fn parse_query_body_basic() {
626 let body = Bytes::from(
627 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
628 );
629 let params = parse_query_body(&body);
630 assert_eq!(params.get("Action").unwrap(), "SendMessage");
631 assert_eq!(params.get("MessageBody").unwrap(), "hello");
632 }
633
634 #[test]
635 fn parse_query_body_empty_returns_empty_map() {
636 let body = Bytes::from("");
637 let params = parse_query_body(&body);
638 assert!(params.is_empty());
639 }
640
641 #[test]
642 fn parse_query_body_duplicate_keys_last_wins() {
643 let body = Bytes::from("key=a&key=b");
644 let params = parse_query_body(&body);
645 assert_eq!(params.get("key").unwrap(), "b");
646 }
647
648 #[test]
649 fn parse_query_body_single_key() {
650 let body = Bytes::from("key=value");
651 let params = parse_query_body(&body);
652 assert_eq!(params.get("key").unwrap(), "value");
653 }
654
655 #[test]
656 fn parse_amz_target_ecs() {
657 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
658 assert_eq!(result.service, "ecs");
659 assert_eq!(result.action, "ListClusters");
660 assert_eq!(result.protocol, AwsProtocol::Json);
661 }
662
663 #[test]
664 fn parse_amz_target_invalid_returns_none() {
665 assert!(parse_amz_target("NoDotHere").is_none());
666 assert!(parse_amz_target("").is_none());
667 }
668
669 #[test]
670 fn parse_amz_target_various_prefixes() {
671 assert_eq!(
672 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
673 "sqs"
674 );
675 assert_eq!(
676 parse_amz_target("AmazonSNS.Publish").unwrap().service,
677 "sns"
678 );
679 assert_eq!(
680 parse_amz_target("DynamoDB_20120810.GetItem")
681 .unwrap()
682 .service,
683 "dynamodb"
684 );
685 assert_eq!(
686 parse_amz_target("Logs_20140328.PutLogEvents")
687 .unwrap()
688 .service,
689 "logs"
690 );
691 assert_eq!(
692 parse_amz_target("secretsmanager.GetSecretValue")
693 .unwrap()
694 .service,
695 "secretsmanager"
696 );
697 assert_eq!(
698 parse_amz_target("TrentService.Encrypt").unwrap().service,
699 "kms"
700 );
701 assert_eq!(
702 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
703 .unwrap()
704 .service,
705 "cognito-idp"
706 );
707 assert_eq!(
708 parse_amz_target("AWSStepFunctions.StartExecution")
709 .unwrap()
710 .service,
711 "states"
712 );
713 assert_eq!(
714 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
715 .unwrap()
716 .service,
717 "organizations"
718 );
719 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
720 }
721
722 #[test]
723 fn infer_service_from_action_maps_sts() {
724 assert_eq!(
725 infer_service_from_action("AssumeRole").as_deref(),
726 Some("sts")
727 );
728 assert_eq!(
729 infer_service_from_action("GetCallerIdentity").as_deref(),
730 Some("sts")
731 );
732 }
733
734 #[test]
735 fn infer_service_from_action_maps_iam() {
736 assert_eq!(
737 infer_service_from_action("CreateUser").as_deref(),
738 Some("iam")
739 );
740 assert_eq!(
741 infer_service_from_action("ListRoles").as_deref(),
742 Some("iam")
743 );
744 }
745
746 #[test]
747 fn infer_service_from_action_maps_ses() {
748 assert_eq!(
749 infer_service_from_action("SendEmail").as_deref(),
750 Some("ses")
751 );
752 assert_eq!(
753 infer_service_from_action("ListIdentities").as_deref(),
754 Some("ses")
755 );
756 }
757
758 #[test]
759 fn infer_service_from_action_unknown_returns_none() {
760 assert!(infer_service_from_action("NotARealAction").is_none());
761 }
762
763 #[test]
764 fn rest_protocol_for_returns_none_for_non_rest_service() {
765 assert!(rest_protocol_for("sqs").is_none());
766 }
767
768 #[test]
769 fn url_decode_handles_percent_and_plus() {
770 assert_eq!(url_decode("hello+world"), "hello world");
771 assert_eq!(url_decode("hello%20world"), "hello world");
772 assert_eq!(url_decode("100%25"), "100%");
773 }
774
775 #[test]
776 fn url_decode_ignores_malformed_percent() {
777 assert_eq!(url_decode("%ZZ"), "");
778 }
779
780 #[test]
781 fn from_hex_valid_digits() {
782 assert_eq!(from_hex(b'0'), Some(0));
783 assert_eq!(from_hex(b'9'), Some(9));
784 assert_eq!(from_hex(b'a'), Some(10));
785 assert_eq!(from_hex(b'F'), Some(15));
786 }
787
788 #[test]
789 fn from_hex_invalid_returns_none() {
790 assert!(from_hex(b'g').is_none());
791 assert!(from_hex(b' ').is_none());
792 }
793
794 #[test]
795 fn detect_service_via_amz_target() {
796 let mut headers = HeaderMap::new();
797 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
798 let query = HashMap::new();
799 let body = Bytes::new();
800 let detected = detect_service(&headers, &query, &body).unwrap();
801 assert_eq!(detected.service, "ssm");
802 assert_eq!(detected.action, "GetParameter");
803 }
804
805 #[test]
806 fn detect_service_via_query_action_with_inferred_service() {
807 let headers = HeaderMap::new();
808 let mut query = HashMap::new();
809 query.insert("Action".to_string(), "AssumeRole".to_string());
810 let body = Bytes::new();
811 let detected = detect_service(&headers, &query, &body).unwrap();
812 assert_eq!(detected.service, "sts");
813 assert_eq!(detected.action, "AssumeRole");
814 assert_eq!(detected.protocol, AwsProtocol::Query);
815 }
816
817 #[test]
818 fn detect_service_via_form_body() {
819 let headers = HeaderMap::new();
820 let query = HashMap::new();
821 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
822 let detected = detect_service(&headers, &query, &body).unwrap();
823 assert_eq!(detected.service, "ses");
824 assert_eq!(detected.action, "SendEmail");
825 }
826
827 #[test]
828 fn detect_service_via_sigv2_presigned() {
829 let headers = HeaderMap::new();
830 let mut query = HashMap::new();
831 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
832 query.insert("Signature".to_string(), "sig".to_string());
833 query.insert("Expires".to_string(), "1234567890".to_string());
834 let body = Bytes::new();
835 let detected = detect_service(&headers, &query, &body).unwrap();
836 assert_eq!(detected.service, "s3");
837 assert_eq!(detected.protocol, AwsProtocol::Rest);
838 }
839
840 #[test]
841 fn detect_service_via_sigv4_presigned_credential() {
842 let headers = HeaderMap::new();
843 let mut query = HashMap::new();
844 query.insert(
845 "X-Amz-Credential".to_string(),
846 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
847 );
848 let body = Bytes::new();
849 let detected = detect_service(&headers, &query, &body).unwrap();
850 assert_eq!(detected.service, "s3");
851 assert_eq!(detected.protocol, AwsProtocol::Rest);
852 }
853
854 #[test]
855 fn detect_service_unknown_returns_none() {
856 let headers = HeaderMap::new();
857 let query = HashMap::new();
858 let body = Bytes::new();
859 assert!(detect_service(&headers, &query, &body).is_none());
860 }
861
862 #[test]
863 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
864 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
869 }
870
871 #[test]
872 fn normalize_service_name_passes_through_unaliased_services() {
873 assert_eq!(normalize_service_name("bedrock"), "bedrock");
877 assert_eq!(normalize_service_name("s3"), "s3");
878 assert_eq!(normalize_service_name("lambda"), "lambda");
879 assert_eq!(normalize_service_name(""), "");
880 assert_eq!(
881 normalize_service_name("unknown-future-service"),
882 "unknown-future-service"
883 );
884 }
885
886 #[test]
887 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
888 let mut headers = HeaderMap::new();
893 headers.insert(
894 "authorization",
895 "AWS4-HMAC-SHA256 \
896 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
897 SignedHeaders=host, Signature=abc"
898 .parse()
899 .unwrap(),
900 );
901 let query = HashMap::new();
902 let body = Bytes::new();
903 let detected = detect_service(&headers, &query, &body).unwrap();
904 assert_eq!(detected.service, "bedrock");
905 assert_eq!(detected.protocol, AwsProtocol::RestJson);
906 }
907
908 #[test]
909 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
910 let headers = HeaderMap::new();
914 let mut query = HashMap::new();
915 query.insert(
916 "X-Amz-Credential".to_string(),
917 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
918 );
919 let body = Bytes::new();
920 let detected = detect_service(&headers, &query, &body).unwrap();
921 assert_eq!(detected.service, "bedrock");
922 assert_eq!(detected.protocol, AwsProtocol::RestJson);
923 }
924
925 #[test]
926 fn parse_routing_host_localstack_basic() {
927 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
928 assert_eq!(h.service, "sqs");
929 assert_eq!(h.region, "us-east-1");
930 assert!(h.bucket.is_none());
931 }
932
933 #[test]
934 fn parse_routing_host_localstack_with_port() {
935 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
936 assert_eq!(h.service, "lambda");
937 assert_eq!(h.region, "eu-west-1");
938 assert!(h.bucket.is_none());
939 }
940
941 #[test]
942 fn parse_routing_host_case_insensitive() {
943 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
944 assert_eq!(h.service, "sqs");
945 assert_eq!(h.region, "us-east-1");
946
947 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
948 assert_eq!(h.service, "lambda");
949 assert_eq!(h.region, "us-east-1");
950 }
951
952 #[test]
953 fn parse_routing_host_localstack_s3_virtual_hosted() {
954 let h =
955 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
956 assert_eq!(h.service, "s3");
957 assert_eq!(h.region, "us-east-1");
958 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
959 }
960
961 #[test]
962 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
963 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
964 assert_eq!(h.service, "s3");
965 assert_eq!(h.region, "us-east-1");
966 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
967 }
968
969 #[test]
970 fn parse_routing_host_aws_service_region() {
971 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
972 assert_eq!(h.service, "sqs");
973 assert_eq!(h.region, "us-east-1");
974 assert!(h.bucket.is_none());
975
976 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
977 assert_eq!(h.service, "dynamodb");
978 assert_eq!(h.region, "eu-west-2");
979 }
980
981 #[test]
982 fn parse_routing_host_aws_s3_path_style_modern() {
983 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
984 assert_eq!(h.service, "s3");
985 assert_eq!(h.region, "us-east-1");
986 assert!(h.bucket.is_none());
987 }
988
989 #[test]
990 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
991 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
992 assert_eq!(h.service, "s3");
993 assert_eq!(h.region, "us-east-1");
994 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
995 }
996
997 #[test]
998 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
999 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1000 assert_eq!(h.service, "s3");
1001 assert_eq!(h.region, "us-east-1");
1002 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1003 }
1004
1005 #[test]
1006 fn parse_routing_host_aws_s3_legacy_global() {
1007 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1010 assert_eq!(h.service, "s3");
1011 assert_eq!(h.region, "us-east-1");
1012 assert!(h.bucket.is_none());
1013
1014 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1015 assert_eq!(h.service, "s3");
1016 assert_eq!(h.region, "us-east-1");
1017 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1018 }
1019
1020 #[test]
1021 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1022 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1025 assert_eq!(h.service, "s3");
1026 assert_eq!(h.region, "us-east-1");
1027 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1028 }
1029
1030 #[test]
1031 fn parse_routing_host_aws_s3_dash_separated() {
1032 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1034 assert_eq!(h.service, "s3");
1035 assert_eq!(h.region, "us-west-2");
1036 assert!(h.bucket.is_none());
1037
1038 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1039 assert_eq!(h.service, "s3");
1040 assert_eq!(h.region, "us-west-2");
1041 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1042 }
1043
1044 #[test]
1045 fn parse_routing_host_rejects_plain_localhost() {
1046 assert!(parse_routing_host("localhost:4566").is_none());
1047 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1048 }
1049
1050 #[test]
1051 fn parse_routing_host_rejects_unknown_suffix() {
1052 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1053 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1054 }
1055
1056 #[test]
1057 fn parse_routing_host_empty_and_malformed_rejected() {
1058 assert!(parse_routing_host("").is_none());
1059 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1060 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1061 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1062 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1063 assert!(parse_routing_host(".amazonaws.com").is_none());
1064 assert!(parse_routing_host("amazonaws.com").is_none());
1065 }
1066
1067 #[test]
1068 fn detect_service_via_host_for_rest_service() {
1069 let mut headers = HeaderMap::new();
1070 headers.insert(
1071 "host",
1072 "s3.us-east-1.localhost.localstack.cloud:4566"
1073 .parse()
1074 .unwrap(),
1075 );
1076 let query = HashMap::new();
1077 let body = Bytes::new();
1078 let detected = detect_service(&headers, &query, &body).unwrap();
1079 assert_eq!(detected.service, "s3");
1080 assert_eq!(detected.protocol, AwsProtocol::Rest);
1081 }
1082
1083 #[test]
1084 fn detect_service_via_host_for_rest_json_service() {
1085 let mut headers = HeaderMap::new();
1086 headers.insert(
1087 "host",
1088 "lambda.us-east-1.localhost.localstack.cloud:4566"
1089 .parse()
1090 .unwrap(),
1091 );
1092 let query = HashMap::new();
1093 let body = Bytes::new();
1094 let detected = detect_service(&headers, &query, &body).unwrap();
1095 assert_eq!(detected.service, "lambda");
1096 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1097 }
1098
1099 #[test]
1100 fn detect_service_via_host_plus_query_action() {
1101 let mut headers = HeaderMap::new();
1102 headers.insert(
1103 "host",
1104 "sqs.us-east-1.localhost.localstack.cloud:4566"
1105 .parse()
1106 .unwrap(),
1107 );
1108 let mut query = HashMap::new();
1109 query.insert("Action".to_string(), "ListQueues".to_string());
1110 let body = Bytes::new();
1111 let detected = detect_service(&headers, &query, &body).unwrap();
1112 assert_eq!(detected.service, "sqs");
1113 assert_eq!(detected.action, "ListQueues");
1114 assert_eq!(detected.protocol, AwsProtocol::Query);
1115 }
1116
1117 #[test]
1118 fn detect_service_sigv4_wins_over_host() {
1119 let mut headers = HeaderMap::new();
1120 headers.insert(
1121 "authorization",
1122 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1123 SignedHeaders=host, Signature=abc"
1124 .parse()
1125 .unwrap(),
1126 );
1127 headers.insert(
1128 "host",
1129 "lambda.us-east-1.localhost.localstack.cloud:4566"
1130 .parse()
1131 .unwrap(),
1132 );
1133 let query = HashMap::new();
1134 let body = Bytes::new();
1135 let detected = detect_service(&headers, &query, &body).unwrap();
1136 assert_eq!(detected.service, "s3");
1138 assert_eq!(detected.protocol, AwsProtocol::Rest);
1139 }
1140
1141 #[test]
1142 fn detect_service_host_for_virtual_hosted_s3() {
1143 let mut headers = HeaderMap::new();
1144 headers.insert(
1145 "host",
1146 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1147 .parse()
1148 .unwrap(),
1149 );
1150 let query = HashMap::new();
1151 let body = Bytes::new();
1152 let detected = detect_service(&headers, &query, &body).unwrap();
1153 assert_eq!(detected.service, "s3");
1154 assert_eq!(detected.protocol, AwsProtocol::Rest);
1155 }
1156}