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 if labels.len() >= 3 {
369 let bucket = labels[..labels.len() - 2].join(".");
370 return Some(RoutingHost {
371 service: "s3".to_string(),
372 region: labels[labels.len() - 1].to_string(),
373 bucket: Some(bucket),
374 });
375 }
376 }
377
378 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
381 return Some(RoutingHost {
382 service: "s3".to_string(),
383 region: last.to_string(),
384 bucket: None,
385 });
386 }
387
388 match labels.len() {
389 2 => Some(RoutingHost {
392 service: labels[0].to_string(),
393 region: labels[1].to_string(),
394 bucket: None,
395 }),
396 n if n >= 3 && labels[n - 2] == "s3" => {
398 let bucket = labels[..n - 2].join(".");
399 Some(RoutingHost {
400 service: "s3".to_string(),
401 region: labels[n - 1].to_string(),
402 bucket: Some(bucket),
403 })
404 }
405 _ => None,
406 }
407}
408
409fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
412 let (prefix, action) = target.rsplit_once('.')?;
413
414 let service = match prefix {
415 "AWSEvents" => "events",
416 "AmazonSSM" => "ssm",
417 "AmazonSQS" => "sqs",
418 "AmazonSNS" => "sns",
419 "DynamoDB_20120810" => "dynamodb",
420 "DynamoDBStreams_20120810" => "dynamodbstreams",
421 "Logs_20140328" => "logs",
422 s if s.starts_with("secretsmanager") => "secretsmanager",
423 s if s.starts_with("TrentService") => "kms",
424 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
425 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
426 s if s.starts_with("Kinesis_20131202") => "kinesis",
427 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
428 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
429 s if s.starts_with("AWSStepFunctions") => "states",
430 s if s.starts_with("AWSOrganizationsV") => "organizations",
431 "CertificateManager" => "acm",
432 "AnyScaleFrontendService" => "application-autoscaling",
433 "AWSWAF_20190729" => "wafv2",
436 "AmazonAthena" => "athena",
437 s if s.starts_with("Firehose_") => "firehose",
438 "AWSGlue" => "glue",
439 _ => return None,
440 };
441
442 Some(DetectedRequest {
443 service: service.to_string(),
444 action: action.to_string(),
445 protocol: AwsProtocol::Json,
446 })
447}
448
449fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
451 if REST_XML_SERVICES.contains(&service) {
452 Some(AwsProtocol::Rest)
453 } else if REST_JSON_SERVICES.contains(&service) {
454 Some(AwsProtocol::RestJson)
455 } else {
456 None
457 }
458}
459
460fn infer_service_from_action(action: &str) -> Option<String> {
464 match action {
465 "AssumeRole"
466 | "AssumeRoleWithSAML"
467 | "AssumeRoleWithWebIdentity"
468 | "GetCallerIdentity"
469 | "GetSessionToken"
470 | "GetFederationToken"
471 | "GetAccessKeyInfo"
472 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
473 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
474 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
475 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
476 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
477 "VerifyEmailIdentity"
479 | "VerifyDomainIdentity"
480 | "VerifyDomainDkim"
481 | "ListIdentities"
482 | "GetIdentityVerificationAttributes"
483 | "GetIdentityDkimAttributes"
484 | "DeleteIdentity"
485 | "SetIdentityDkimEnabled"
486 | "SetIdentityNotificationTopic"
487 | "SetIdentityFeedbackForwardingEnabled"
488 | "GetIdentityNotificationAttributes"
489 | "GetIdentityMailFromDomainAttributes"
490 | "SetIdentityMailFromDomain"
491 | "SendEmail"
492 | "SendRawEmail"
493 | "SendTemplatedEmail"
494 | "SendBulkTemplatedEmail"
495 | "CreateTemplate"
496 | "GetTemplate"
497 | "ListTemplates"
498 | "DeleteTemplate"
499 | "UpdateTemplate"
500 | "CreateConfigurationSet"
501 | "DeleteConfigurationSet"
502 | "DescribeConfigurationSet"
503 | "ListConfigurationSets"
504 | "CreateConfigurationSetEventDestination"
505 | "UpdateConfigurationSetEventDestination"
506 | "DeleteConfigurationSetEventDestination"
507 | "GetSendQuota"
508 | "GetSendStatistics"
509 | "GetAccountSendingEnabled"
510 | "CreateReceiptRuleSet"
511 | "DeleteReceiptRuleSet"
512 | "DescribeReceiptRuleSet"
513 | "ListReceiptRuleSets"
514 | "CloneReceiptRuleSet"
515 | "SetActiveReceiptRuleSet"
516 | "ReorderReceiptRuleSet"
517 | "CreateReceiptRule"
518 | "DeleteReceiptRule"
519 | "DescribeReceiptRule"
520 | "UpdateReceiptRule"
521 | "CreateReceiptFilter"
522 | "DeleteReceiptFilter"
523 | "ListReceiptFilters" => Some("ses".to_string()),
524 _ => None,
525 }
526}
527
528fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
530 let auth = headers.get("authorization")?.to_str().ok()?;
531 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
532 Some(normalize_service_name(&info.service).to_string())
533}
534
535fn normalize_service_name(service: &str) -> &str {
547 match service {
548 "bedrock-runtime" => "bedrock",
549 "apigatewayv2" => "apigateway",
557 other => other,
558 }
559}
560
561pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
563 decode_form_urlencoded(body)
564}
565
566fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
567 let s = std::str::from_utf8(input).unwrap_or("");
568 let mut result = HashMap::new();
569 for pair in s.split('&') {
570 if pair.is_empty() {
571 continue;
572 }
573 let (key, value) = match pair.find('=') {
574 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
575 None => (pair, ""),
576 };
577 result.insert(url_decode(key), url_decode(value));
578 }
579 result
580}
581
582fn url_decode(input: &str) -> String {
583 let mut result = String::with_capacity(input.len());
584 let mut bytes = input.bytes();
585 while let Some(b) = bytes.next() {
586 match b {
587 b'+' => result.push(' '),
588 b'%' => {
589 let high = bytes.next().and_then(from_hex);
590 let low = bytes.next().and_then(from_hex);
591 if let (Some(h), Some(l)) = (high, low) {
592 result.push((h << 4 | l) as char);
593 }
594 }
595 _ => result.push(b as char),
596 }
597 }
598 result
599}
600
601fn from_hex(b: u8) -> Option<u8> {
602 match b {
603 b'0'..=b'9' => Some(b - b'0'),
604 b'a'..=b'f' => Some(b - b'a' + 10),
605 b'A'..=b'F' => Some(b - b'A' + 10),
606 _ => None,
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn parse_amz_target_events() {
616 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
617 assert_eq!(result.service, "events");
618 assert_eq!(result.action, "PutEvents");
619 assert_eq!(result.protocol, AwsProtocol::Json);
620 }
621
622 #[test]
623 fn parse_amz_target_ssm() {
624 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
625 assert_eq!(result.service, "ssm");
626 assert_eq!(result.action, "GetParameter");
627 }
628
629 #[test]
630 fn parse_amz_target_kinesis() {
631 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
632 assert_eq!(result.service, "kinesis");
633 assert_eq!(result.action, "ListStreams");
634 assert_eq!(result.protocol, AwsProtocol::Json);
635 }
636
637 #[test]
638 fn parse_query_body_basic() {
639 let body = Bytes::from(
640 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
641 );
642 let params = parse_query_body(&body);
643 assert_eq!(params.get("Action").unwrap(), "SendMessage");
644 assert_eq!(params.get("MessageBody").unwrap(), "hello");
645 }
646
647 #[test]
648 fn parse_query_body_empty_returns_empty_map() {
649 let body = Bytes::from("");
650 let params = parse_query_body(&body);
651 assert!(params.is_empty());
652 }
653
654 #[test]
655 fn parse_query_body_duplicate_keys_last_wins() {
656 let body = Bytes::from("key=a&key=b");
657 let params = parse_query_body(&body);
658 assert_eq!(params.get("key").unwrap(), "b");
659 }
660
661 #[test]
662 fn parse_query_body_single_key() {
663 let body = Bytes::from("key=value");
664 let params = parse_query_body(&body);
665 assert_eq!(params.get("key").unwrap(), "value");
666 }
667
668 #[test]
669 fn parse_amz_target_ecs() {
670 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
671 assert_eq!(result.service, "ecs");
672 assert_eq!(result.action, "ListClusters");
673 assert_eq!(result.protocol, AwsProtocol::Json);
674 }
675
676 #[test]
677 fn parse_amz_target_invalid_returns_none() {
678 assert!(parse_amz_target("NoDotHere").is_none());
679 assert!(parse_amz_target("").is_none());
680 }
681
682 #[test]
683 fn parse_amz_target_various_prefixes() {
684 assert_eq!(
685 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
686 "sqs"
687 );
688 assert_eq!(
689 parse_amz_target("AmazonSNS.Publish").unwrap().service,
690 "sns"
691 );
692 assert_eq!(
693 parse_amz_target("DynamoDB_20120810.GetItem")
694 .unwrap()
695 .service,
696 "dynamodb"
697 );
698 assert_eq!(
699 parse_amz_target("Logs_20140328.PutLogEvents")
700 .unwrap()
701 .service,
702 "logs"
703 );
704 assert_eq!(
705 parse_amz_target("secretsmanager.GetSecretValue")
706 .unwrap()
707 .service,
708 "secretsmanager"
709 );
710 assert_eq!(
711 parse_amz_target("TrentService.Encrypt").unwrap().service,
712 "kms"
713 );
714 assert_eq!(
715 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
716 .unwrap()
717 .service,
718 "cognito-idp"
719 );
720 assert_eq!(
721 parse_amz_target("AWSStepFunctions.StartExecution")
722 .unwrap()
723 .service,
724 "states"
725 );
726 assert_eq!(
727 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
728 .unwrap()
729 .service,
730 "organizations"
731 );
732 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
733 }
734
735 #[test]
736 fn infer_service_from_action_maps_sts() {
737 assert_eq!(
738 infer_service_from_action("AssumeRole").as_deref(),
739 Some("sts")
740 );
741 assert_eq!(
742 infer_service_from_action("GetCallerIdentity").as_deref(),
743 Some("sts")
744 );
745 }
746
747 #[test]
748 fn infer_service_from_action_maps_iam() {
749 assert_eq!(
750 infer_service_from_action("CreateUser").as_deref(),
751 Some("iam")
752 );
753 assert_eq!(
754 infer_service_from_action("ListRoles").as_deref(),
755 Some("iam")
756 );
757 }
758
759 #[test]
760 fn infer_service_from_action_maps_ses() {
761 assert_eq!(
762 infer_service_from_action("SendEmail").as_deref(),
763 Some("ses")
764 );
765 assert_eq!(
766 infer_service_from_action("ListIdentities").as_deref(),
767 Some("ses")
768 );
769 }
770
771 #[test]
772 fn infer_service_from_action_unknown_returns_none() {
773 assert!(infer_service_from_action("NotARealAction").is_none());
774 }
775
776 #[test]
777 fn rest_protocol_for_returns_none_for_non_rest_service() {
778 assert!(rest_protocol_for("sqs").is_none());
779 }
780
781 #[test]
782 fn url_decode_handles_percent_and_plus() {
783 assert_eq!(url_decode("hello+world"), "hello world");
784 assert_eq!(url_decode("hello%20world"), "hello world");
785 assert_eq!(url_decode("100%25"), "100%");
786 }
787
788 #[test]
789 fn url_decode_ignores_malformed_percent() {
790 assert_eq!(url_decode("%ZZ"), "");
791 }
792
793 #[test]
794 fn from_hex_valid_digits() {
795 assert_eq!(from_hex(b'0'), Some(0));
796 assert_eq!(from_hex(b'9'), Some(9));
797 assert_eq!(from_hex(b'a'), Some(10));
798 assert_eq!(from_hex(b'F'), Some(15));
799 }
800
801 #[test]
802 fn from_hex_invalid_returns_none() {
803 assert!(from_hex(b'g').is_none());
804 assert!(from_hex(b' ').is_none());
805 }
806
807 #[test]
808 fn detect_service_via_amz_target() {
809 let mut headers = HeaderMap::new();
810 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
811 let query = HashMap::new();
812 let body = Bytes::new();
813 let detected = detect_service(&headers, &query, &body).unwrap();
814 assert_eq!(detected.service, "ssm");
815 assert_eq!(detected.action, "GetParameter");
816 }
817
818 #[test]
819 fn detect_service_via_query_action_with_inferred_service() {
820 let headers = HeaderMap::new();
821 let mut query = HashMap::new();
822 query.insert("Action".to_string(), "AssumeRole".to_string());
823 let body = Bytes::new();
824 let detected = detect_service(&headers, &query, &body).unwrap();
825 assert_eq!(detected.service, "sts");
826 assert_eq!(detected.action, "AssumeRole");
827 assert_eq!(detected.protocol, AwsProtocol::Query);
828 }
829
830 #[test]
831 fn detect_service_via_form_body() {
832 let headers = HeaderMap::new();
833 let query = HashMap::new();
834 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
835 let detected = detect_service(&headers, &query, &body).unwrap();
836 assert_eq!(detected.service, "ses");
837 assert_eq!(detected.action, "SendEmail");
838 }
839
840 #[test]
841 fn detect_service_via_sigv2_presigned() {
842 let headers = HeaderMap::new();
843 let mut query = HashMap::new();
844 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
845 query.insert("Signature".to_string(), "sig".to_string());
846 query.insert("Expires".to_string(), "1234567890".to_string());
847 let body = Bytes::new();
848 let detected = detect_service(&headers, &query, &body).unwrap();
849 assert_eq!(detected.service, "s3");
850 assert_eq!(detected.protocol, AwsProtocol::Rest);
851 }
852
853 #[test]
854 fn detect_service_via_sigv4_presigned_credential() {
855 let headers = HeaderMap::new();
856 let mut query = HashMap::new();
857 query.insert(
858 "X-Amz-Credential".to_string(),
859 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
860 );
861 let body = Bytes::new();
862 let detected = detect_service(&headers, &query, &body).unwrap();
863 assert_eq!(detected.service, "s3");
864 assert_eq!(detected.protocol, AwsProtocol::Rest);
865 }
866
867 #[test]
868 fn detect_service_unknown_returns_none() {
869 let headers = HeaderMap::new();
870 let query = HashMap::new();
871 let body = Bytes::new();
872 assert!(detect_service(&headers, &query, &body).is_none());
873 }
874
875 #[test]
876 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
877 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
882 }
883
884 #[test]
885 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
886 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
891 }
892
893 #[test]
894 fn normalize_service_name_passes_through_unaliased_services() {
895 assert_eq!(normalize_service_name("bedrock"), "bedrock");
899 assert_eq!(normalize_service_name("s3"), "s3");
900 assert_eq!(normalize_service_name("lambda"), "lambda");
901 assert_eq!(normalize_service_name(""), "");
902 assert_eq!(
903 normalize_service_name("unknown-future-service"),
904 "unknown-future-service"
905 );
906 }
907
908 #[test]
909 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
910 let mut headers = HeaderMap::new();
915 headers.insert(
916 "authorization",
917 "AWS4-HMAC-SHA256 \
918 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
919 SignedHeaders=host, Signature=abc"
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, "bedrock");
927 assert_eq!(detected.protocol, AwsProtocol::RestJson);
928 }
929
930 #[test]
931 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
932 let headers = HeaderMap::new();
936 let mut query = HashMap::new();
937 query.insert(
938 "X-Amz-Credential".to_string(),
939 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
940 );
941 let body = Bytes::new();
942 let detected = detect_service(&headers, &query, &body).unwrap();
943 assert_eq!(detected.service, "bedrock");
944 assert_eq!(detected.protocol, AwsProtocol::RestJson);
945 }
946
947 #[test]
948 fn parse_routing_host_localstack_basic() {
949 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
950 assert_eq!(h.service, "sqs");
951 assert_eq!(h.region, "us-east-1");
952 assert!(h.bucket.is_none());
953 }
954
955 #[test]
956 fn parse_routing_host_localstack_with_port() {
957 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
958 assert_eq!(h.service, "lambda");
959 assert_eq!(h.region, "eu-west-1");
960 assert!(h.bucket.is_none());
961 }
962
963 #[test]
964 fn parse_routing_host_case_insensitive() {
965 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
966 assert_eq!(h.service, "sqs");
967 assert_eq!(h.region, "us-east-1");
968
969 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
970 assert_eq!(h.service, "lambda");
971 assert_eq!(h.region, "us-east-1");
972 }
973
974 #[test]
975 fn parse_routing_host_localstack_s3_virtual_hosted() {
976 let h =
977 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
978 assert_eq!(h.service, "s3");
979 assert_eq!(h.region, "us-east-1");
980 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
981 }
982
983 #[test]
984 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
985 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
986 assert_eq!(h.service, "s3");
987 assert_eq!(h.region, "us-east-1");
988 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
989 }
990
991 #[test]
992 fn parse_routing_host_aws_service_region() {
993 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
994 assert_eq!(h.service, "sqs");
995 assert_eq!(h.region, "us-east-1");
996 assert!(h.bucket.is_none());
997
998 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
999 assert_eq!(h.service, "dynamodb");
1000 assert_eq!(h.region, "eu-west-2");
1001 }
1002
1003 #[test]
1004 fn parse_routing_host_aws_s3_path_style_modern() {
1005 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1006 assert_eq!(h.service, "s3");
1007 assert_eq!(h.region, "us-east-1");
1008 assert!(h.bucket.is_none());
1009 }
1010
1011 #[test]
1012 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1013 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1014 assert_eq!(h.service, "s3");
1015 assert_eq!(h.region, "us-east-1");
1016 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1017 }
1018
1019 #[test]
1020 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1021 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1022 assert_eq!(h.service, "s3");
1023 assert_eq!(h.region, "us-east-1");
1024 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1025 }
1026
1027 #[test]
1028 fn parse_routing_host_aws_s3_legacy_global() {
1029 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1032 assert_eq!(h.service, "s3");
1033 assert_eq!(h.region, "us-east-1");
1034 assert!(h.bucket.is_none());
1035
1036 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1037 assert_eq!(h.service, "s3");
1038 assert_eq!(h.region, "us-east-1");
1039 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1040 }
1041
1042 #[test]
1043 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1044 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1047 assert_eq!(h.service, "s3");
1048 assert_eq!(h.region, "us-east-1");
1049 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1050 }
1051
1052 #[test]
1053 fn parse_routing_host_aws_s3_dash_separated() {
1054 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1056 assert_eq!(h.service, "s3");
1057 assert_eq!(h.region, "us-west-2");
1058 assert!(h.bucket.is_none());
1059
1060 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1061 assert_eq!(h.service, "s3");
1062 assert_eq!(h.region, "us-west-2");
1063 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1064 }
1065
1066 #[test]
1067 fn parse_routing_host_rejects_plain_localhost() {
1068 assert!(parse_routing_host("localhost:4566").is_none());
1069 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1070 }
1071
1072 #[test]
1073 fn parse_routing_host_rejects_unknown_suffix() {
1074 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1075 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1076 }
1077
1078 #[test]
1079 fn parse_routing_host_empty_and_malformed_rejected() {
1080 assert!(parse_routing_host("").is_none());
1081 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1082 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1083 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1084 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1085 assert!(parse_routing_host(".amazonaws.com").is_none());
1086 assert!(parse_routing_host("amazonaws.com").is_none());
1087 }
1088
1089 #[test]
1090 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1091 assert!(parse_routing_host("s3-accesspoint").is_none());
1095 }
1096
1097 #[test]
1098 fn detect_service_via_host_for_rest_service() {
1099 let mut headers = HeaderMap::new();
1100 headers.insert(
1101 "host",
1102 "s3.us-east-1.localhost.localstack.cloud:4566"
1103 .parse()
1104 .unwrap(),
1105 );
1106 let query = HashMap::new();
1107 let body = Bytes::new();
1108 let detected = detect_service(&headers, &query, &body).unwrap();
1109 assert_eq!(detected.service, "s3");
1110 assert_eq!(detected.protocol, AwsProtocol::Rest);
1111 }
1112
1113 #[test]
1114 fn detect_service_via_host_for_rest_json_service() {
1115 let mut headers = HeaderMap::new();
1116 headers.insert(
1117 "host",
1118 "lambda.us-east-1.localhost.localstack.cloud:4566"
1119 .parse()
1120 .unwrap(),
1121 );
1122 let query = HashMap::new();
1123 let body = Bytes::new();
1124 let detected = detect_service(&headers, &query, &body).unwrap();
1125 assert_eq!(detected.service, "lambda");
1126 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1127 }
1128
1129 #[test]
1130 fn detect_service_via_host_plus_query_action() {
1131 let mut headers = HeaderMap::new();
1132 headers.insert(
1133 "host",
1134 "sqs.us-east-1.localhost.localstack.cloud:4566"
1135 .parse()
1136 .unwrap(),
1137 );
1138 let mut query = HashMap::new();
1139 query.insert("Action".to_string(), "ListQueues".to_string());
1140 let body = Bytes::new();
1141 let detected = detect_service(&headers, &query, &body).unwrap();
1142 assert_eq!(detected.service, "sqs");
1143 assert_eq!(detected.action, "ListQueues");
1144 assert_eq!(detected.protocol, AwsProtocol::Query);
1145 }
1146
1147 #[test]
1148 fn detect_service_sigv4_wins_over_host() {
1149 let mut headers = HeaderMap::new();
1150 headers.insert(
1151 "authorization",
1152 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1153 SignedHeaders=host, Signature=abc"
1154 .parse()
1155 .unwrap(),
1156 );
1157 headers.insert(
1158 "host",
1159 "lambda.us-east-1.localhost.localstack.cloud:4566"
1160 .parse()
1161 .unwrap(),
1162 );
1163 let query = HashMap::new();
1164 let body = Bytes::new();
1165 let detected = detect_service(&headers, &query, &body).unwrap();
1166 assert_eq!(detected.service, "s3");
1168 assert_eq!(detected.protocol, AwsProtocol::Rest);
1169 }
1170
1171 #[test]
1172 fn detect_service_host_for_virtual_hosted_s3() {
1173 let mut headers = HeaderMap::new();
1174 headers.insert(
1175 "host",
1176 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1177 .parse()
1178 .unwrap(),
1179 );
1180 let query = HashMap::new();
1181 let body = Bytes::new();
1182 let detected = detect_service(&headers, &query, &body).unwrap();
1183 assert_eq!(detected.service, "s3");
1184 assert_eq!(detected.protocol, AwsProtocol::Rest);
1185 }
1186}