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