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