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 "pipes",
36 "rds-data",
37 "dsql",
38 "resource-groups",
39 "eks",
40 "backup",
41 "es",
45 "account",
46];
47
48#[derive(Debug, Clone)]
50pub struct DetectedRequest {
51 pub service: String,
52 pub action: String,
53 pub protocol: AwsProtocol,
54}
55
56pub fn detect_service_headers_only(
63 headers: &HeaderMap,
64 query_params: &HashMap<String, String>,
65) -> Option<DetectedRequest> {
66 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
68 return parse_amz_target(target);
69 }
70 if let Some(action) = query_params.get("Action") {
71 let service = extract_service_from_auth(headers)
72 .or_else(|| infer_service_from_action(action))
73 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
74 if let Some(service) = service {
75 return Some(DetectedRequest {
76 service,
77 action: action.clone(),
78 protocol: AwsProtocol::Query,
79 });
80 }
81 }
82 if let Some(service) = extract_service_from_auth(headers) {
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 if let Some(credential) = query_params.get("X-Amz-Credential") {
92 let parts: Vec<&str> = credential.split('/').collect();
93 if parts.len() >= 4 {
94 let service = normalize_service_name(parts[3]).to_string();
95 if let Some(protocol) = rest_protocol_for(&service) {
96 return Some(DetectedRequest {
97 service,
98 action: String::new(),
99 protocol,
100 });
101 }
102 }
103 }
104 if query_params.contains_key("AWSAccessKeyId")
105 && query_params.contains_key("Signature")
106 && query_params.contains_key("Expires")
107 {
108 return Some(DetectedRequest {
109 service: "s3".to_string(),
110 action: String::new(),
111 protocol: AwsProtocol::Rest,
112 });
113 }
114 if let Some(host_info) = parse_routing_host_from_headers(headers) {
115 if let Some(protocol) = rest_protocol_for(&host_info.service) {
116 return Some(DetectedRequest {
117 service: host_info.service,
118 action: String::new(),
119 protocol,
120 });
121 }
122 }
123 None
124}
125
126pub fn detect_service(
128 headers: &HeaderMap,
129 query_params: &HashMap<String, String>,
130 body: &Bytes,
131) -> Option<DetectedRequest> {
132 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
134 return parse_amz_target(target);
135 }
136
137 if let Some(action) = query_params.get("Action") {
139 let service = extract_service_from_auth(headers)
140 .or_else(|| infer_service_from_action(action))
141 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
142 if let Some(service) = service {
143 return Some(DetectedRequest {
144 service,
145 action: action.clone(),
146 protocol: AwsProtocol::Query,
147 });
148 }
149 }
150
151 {
153 let form_params = decode_form_urlencoded(body);
154
155 if let Some(action) = form_params.get("Action") {
156 let service = extract_service_from_auth(headers)
157 .or_else(|| infer_service_from_action(action))
158 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
159 if let Some(service) = service {
160 return Some(DetectedRequest {
161 service,
162 action: action.clone(),
163 protocol: AwsProtocol::Query,
164 });
165 }
166 }
167 }
168
169 if let Some(service) = extract_service_from_auth(headers) {
171 if let Some(protocol) = rest_protocol_for(&service) {
172 return Some(DetectedRequest {
173 service,
174 action: String::new(), protocol,
176 });
177 }
178 }
179
180 if let Some(credential) = query_params.get("X-Amz-Credential") {
182 let parts: Vec<&str> = credential.split('/').collect();
184 if parts.len() >= 4 {
185 let service = normalize_service_name(parts[3]).to_string();
186 if let Some(protocol) = rest_protocol_for(&service) {
187 return Some(DetectedRequest {
188 service,
189 action: String::new(),
190 protocol,
191 });
192 }
193 }
194 }
195
196 if query_params.contains_key("AWSAccessKeyId")
200 && query_params.contains_key("Signature")
201 && query_params.contains_key("Expires")
202 {
203 return Some(DetectedRequest {
204 service: "s3".to_string(),
205 action: String::new(),
206 protocol: AwsProtocol::Rest,
207 });
208 }
209
210 if let Some(host_info) = parse_routing_host_from_headers(headers) {
214 if let Some(protocol) = rest_protocol_for(&host_info.service) {
215 return Some(DetectedRequest {
216 service: host_info.service,
217 action: String::new(),
218 protocol,
219 });
220 }
221 }
222
223 None
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct RoutingHost {
236 pub service: String,
237 pub region: String,
238 pub bucket: Option<String>,
240}
241
242const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
243const AWS_SUFFIX: &str = ".amazonaws.com";
244
245pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
249 let hostname = host.split(':').next()?;
250 if hostname.is_empty() {
251 return None;
252 }
253 let hostname = hostname.to_ascii_lowercase();
254 if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
255 return parse_localstack_prefix(prefix);
256 }
257 if hostname == "amazonaws.com" {
258 return None;
259 }
260 if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
261 return parse_aws_prefix(prefix);
262 }
263 None
264}
265
266pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
268 let host = headers.get("host")?.to_str().ok()?;
269 parse_routing_host(host)
270}
271
272fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
273 if prefix.is_empty() {
274 return None;
275 }
276 let labels: Vec<&str> = prefix.split('.').collect();
277 if labels.iter().any(|l| l.is_empty()) {
278 return None;
279 }
280 match labels.len() {
281 2 => Some(RoutingHost {
282 service: labels[0].to_string(),
283 region: labels[1].to_string(),
284 bucket: None,
285 }),
286 n if n >= 3 && labels[n - 2] == "s3" => {
287 let bucket = labels[..n - 2].join(".");
288 Some(RoutingHost {
289 service: "s3".to_string(),
290 region: labels[n - 1].to_string(),
291 bucket: Some(bucket),
292 })
293 }
294 n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
295 let bucket = labels[..n - 2].join(".");
296 Some(RoutingHost {
297 service: "s3".to_string(),
298 region: labels[n - 1].to_string(),
299 bucket: Some(bucket),
300 })
301 }
302 n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
303 service: "s3".to_string(),
304 region: labels[n - 1].to_string(),
305 bucket: None,
306 }),
307 _ => None,
308 }
309}
310
311fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
323 if prefix.is_empty() {
324 return None;
325 }
326 let labels: Vec<&str> = prefix.split('.').collect();
327 if labels.iter().any(|l| l.is_empty()) {
328 return None;
329 }
330 let last = *labels.last()?;
331
332 if let Some(region) = last.strip_prefix("s3-") {
335 if !region.is_empty() {
336 let bucket = if labels.len() >= 2 {
337 Some(labels[..labels.len() - 1].join("."))
338 } else {
339 None
340 };
341 return Some(RoutingHost {
342 service: "s3".to_string(),
343 region: region.to_string(),
344 bucket,
345 });
346 }
347 }
348
349 if last == "s3" {
353 if labels.len() == 1 {
354 return Some(RoutingHost {
355 service: "s3".to_string(),
356 region: "us-east-1".to_string(),
357 bucket: None,
358 });
359 }
360 return Some(RoutingHost {
361 service: "s3".to_string(),
362 region: "us-east-1".to_string(),
363 bucket: Some(labels[..labels.len() - 1].join(".")),
364 });
365 }
366
367 if last == "s3-accesspoint" {
370 if labels.len() == 2 {
371 return Some(RoutingHost {
372 service: "s3".to_string(),
373 region: labels[0].to_string(),
374 bucket: None,
375 });
376 }
377 if labels.len() >= 3 {
381 let bucket = labels[..labels.len() - 2].join(".");
382 return Some(RoutingHost {
383 service: "s3".to_string(),
384 region: labels[labels.len() - 1].to_string(),
385 bucket: Some(bucket),
386 });
387 }
388 }
389
390 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
393 return Some(RoutingHost {
394 service: "s3".to_string(),
395 region: last.to_string(),
396 bucket: None,
397 });
398 }
399
400 match labels.len() {
401 2 => Some(RoutingHost {
404 service: labels[0].to_string(),
405 region: labels[1].to_string(),
406 bucket: None,
407 }),
408 n if n >= 3 && labels[n - 2] == "s3" => {
410 let bucket = labels[..n - 2].join(".");
411 Some(RoutingHost {
412 service: "s3".to_string(),
413 region: labels[n - 1].to_string(),
414 bucket: Some(bucket),
415 })
416 }
417 _ => None,
418 }
419}
420
421fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
424 let (prefix, action) = target.rsplit_once('.')?;
425
426 let service = match prefix {
427 "AWSEvents" => "events",
428 "AmazonSSM" => "ssm",
429 "AmazonSQS" => "sqs",
430 "AmazonSNS" => "sns",
431 "DynamoDB_20120810" => "dynamodb",
432 "DynamoDBStreams_20120810" => "dynamodbstreams",
433 "Logs_20140328" => "logs",
434 s if s.starts_with("secretsmanager") => "secretsmanager",
435 s if s.starts_with("TrentService") => "kms",
436 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
437 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
438 s if s.starts_with("Kinesis_20131202") => "kinesis",
439 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
440 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
441 s if s.starts_with("AWSStepFunctions") => "states",
442 s if s.starts_with("AWSOrganizationsV") => "organizations",
443 "CertificateManager" => "acm",
444 "AnyScaleFrontendService" => "application-autoscaling",
445 "AWSWAF_20190729" => "wafv2",
448 "AmazonAthena" => "athena",
449 s if s.starts_with("Firehose_") => "firehose",
450 "AWSGlue" => "glue",
451 "CloudApiService" => "cloudcontrolapi",
452 "ResourceGroupsTaggingAPI_20170126" => "tagging",
453 "AmazonMemoryDB" => "memorydb",
454 "Route53AutoNaming_v20170314" => "servicediscovery",
457 "AmazonDMSv20160101" => "dms",
459 "AWSIdentityStore" => "identitystore",
461 "SWBExternalService" => "sso",
463 "VerifiedPermissions" => "verifiedpermissions",
465 s if s.starts_with("GraniteServiceVersion") => "monitoring",
471 _ => return None,
472 };
473
474 Some(DetectedRequest {
475 service: service.to_string(),
476 action: action.to_string(),
477 protocol: AwsProtocol::Json,
478 })
479}
480
481fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
483 if REST_XML_SERVICES.contains(&service) {
484 Some(AwsProtocol::Rest)
485 } else if REST_JSON_SERVICES.contains(&service) {
486 Some(AwsProtocol::RestJson)
487 } else {
488 None
489 }
490}
491
492fn infer_service_from_action(action: &str) -> Option<String> {
496 match action {
497 "AssumeRole"
498 | "AssumeRoleWithSAML"
499 | "AssumeRoleWithWebIdentity"
500 | "GetCallerIdentity"
501 | "GetSessionToken"
502 | "GetFederationToken"
503 | "GetAccessKeyInfo"
504 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
505 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
506 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
507 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
508 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
509 "VerifyEmailIdentity"
511 | "VerifyDomainIdentity"
512 | "VerifyDomainDkim"
513 | "ListIdentities"
514 | "GetIdentityVerificationAttributes"
515 | "GetIdentityDkimAttributes"
516 | "DeleteIdentity"
517 | "SetIdentityDkimEnabled"
518 | "SetIdentityNotificationTopic"
519 | "SetIdentityFeedbackForwardingEnabled"
520 | "GetIdentityNotificationAttributes"
521 | "GetIdentityMailFromDomainAttributes"
522 | "SetIdentityMailFromDomain"
523 | "SendEmail"
524 | "SendRawEmail"
525 | "SendTemplatedEmail"
526 | "SendBulkTemplatedEmail"
527 | "CreateTemplate"
528 | "GetTemplate"
529 | "ListTemplates"
530 | "DeleteTemplate"
531 | "UpdateTemplate"
532 | "CreateConfigurationSet"
533 | "DeleteConfigurationSet"
534 | "DescribeConfigurationSet"
535 | "ListConfigurationSets"
536 | "CreateConfigurationSetEventDestination"
537 | "UpdateConfigurationSetEventDestination"
538 | "DeleteConfigurationSetEventDestination"
539 | "GetSendQuota"
540 | "GetSendStatistics"
541 | "GetAccountSendingEnabled"
542 | "CreateReceiptRuleSet"
543 | "DeleteReceiptRuleSet"
544 | "DescribeReceiptRuleSet"
545 | "ListReceiptRuleSets"
546 | "CloneReceiptRuleSet"
547 | "SetActiveReceiptRuleSet"
548 | "ReorderReceiptRuleSet"
549 | "CreateReceiptRule"
550 | "DeleteReceiptRule"
551 | "DescribeReceiptRule"
552 | "UpdateReceiptRule"
553 | "CreateReceiptFilter"
554 | "DeleteReceiptFilter"
555 | "ListReceiptFilters" => Some("ses".to_string()),
556 "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
560 _ => None,
561 }
562}
563
564fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
566 let auth = headers.get("authorization")?.to_str().ok()?;
567 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
568 Some(normalize_service_name(&info.service).to_string())
569}
570
571fn normalize_service_name(service: &str) -> &str {
583 match service {
584 "bedrock-runtime" => "bedrock",
585 "apigatewayv2" => "apigateway",
593 "opensearch" => "es",
601 other => other,
602 }
603}
604
605pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
607 decode_form_urlencoded(body)
608}
609
610pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
627 let mut out = HashMap::new();
628 let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
629 return out;
630 };
631 if value.is_object() {
632 flatten_json_value("", &value, &mut out);
633 }
634 out
635}
636
637fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
638 match value {
639 serde_json::Value::Object(map) => {
640 for (k, v) in map {
641 let child = if prefix.is_empty() {
642 k.clone()
643 } else {
644 format!("{prefix}.{k}")
645 };
646 flatten_json_value(&child, v, out);
647 }
648 }
649 serde_json::Value::Array(items) => {
650 for (i, v) in items.iter().enumerate() {
651 let child = format!("{prefix}.member.{}", i + 1);
652 flatten_json_value(&child, v, out);
653 }
654 }
655 serde_json::Value::Null => {}
656 serde_json::Value::String(s) => {
657 out.insert(prefix.to_string(), s.clone());
658 }
659 serde_json::Value::Bool(b) => {
660 out.insert(prefix.to_string(), b.to_string());
661 }
662 serde_json::Value::Number(n) => {
663 out.insert(prefix.to_string(), n.to_string());
664 }
665 }
666}
667
668fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
669 let s = std::str::from_utf8(input).unwrap_or("");
670 let mut result = HashMap::new();
671 for pair in s.split('&') {
672 if pair.is_empty() {
673 continue;
674 }
675 let (key, value) = match pair.find('=') {
676 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
677 None => (pair, ""),
678 };
679 result.insert(url_decode(key), url_decode(value));
680 }
681 result
682}
683
684fn url_decode(input: &str) -> String {
685 let mut result = String::with_capacity(input.len());
686 let mut bytes = input.bytes();
687 while let Some(b) = bytes.next() {
688 match b {
689 b'+' => result.push(' '),
690 b'%' => {
691 let high = bytes.next().and_then(from_hex);
692 let low = bytes.next().and_then(from_hex);
693 if let (Some(h), Some(l)) = (high, low) {
694 result.push((h << 4 | l) as char);
695 }
696 }
697 _ => result.push(b as char),
698 }
699 }
700 result
701}
702
703fn from_hex(b: u8) -> Option<u8> {
704 match b {
705 b'0'..=b'9' => Some(b - b'0'),
706 b'a'..=b'f' => Some(b - b'a' + 10),
707 b'A'..=b'F' => Some(b - b'A' + 10),
708 _ => None,
709 }
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn parse_amz_target_events() {
718 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
719 assert_eq!(result.service, "events");
720 assert_eq!(result.action, "PutEvents");
721 assert_eq!(result.protocol, AwsProtocol::Json);
722 }
723
724 #[test]
725 fn parse_amz_target_ssm() {
726 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
727 assert_eq!(result.service, "ssm");
728 assert_eq!(result.action, "GetParameter");
729 }
730
731 #[test]
732 fn parse_amz_target_kinesis() {
733 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
734 assert_eq!(result.service, "kinesis");
735 assert_eq!(result.action, "ListStreams");
736 assert_eq!(result.protocol, AwsProtocol::Json);
737 }
738
739 #[test]
740 fn parse_query_body_basic() {
741 let body = Bytes::from(
742 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
743 );
744 let params = parse_query_body(&body);
745 assert_eq!(params.get("Action").unwrap(), "SendMessage");
746 assert_eq!(params.get("MessageBody").unwrap(), "hello");
747 }
748
749 #[test]
750 fn parse_query_body_empty_returns_empty_map() {
751 let body = Bytes::from("");
752 let params = parse_query_body(&body);
753 assert!(params.is_empty());
754 }
755
756 #[test]
757 fn parse_query_body_duplicate_keys_last_wins() {
758 let body = Bytes::from("key=a&key=b");
759 let params = parse_query_body(&body);
760 assert_eq!(params.get("key").unwrap(), "b");
761 }
762
763 #[test]
764 fn parse_query_body_single_key() {
765 let body = Bytes::from("key=value");
766 let params = parse_query_body(&body);
767 assert_eq!(params.get("key").unwrap(), "value");
768 }
769
770 #[test]
771 fn parse_amz_target_ecs() {
772 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
773 assert_eq!(result.service, "ecs");
774 assert_eq!(result.action, "ListClusters");
775 assert_eq!(result.protocol, AwsProtocol::Json);
776 }
777
778 #[test]
779 fn parse_amz_target_invalid_returns_none() {
780 assert!(parse_amz_target("NoDotHere").is_none());
781 assert!(parse_amz_target("").is_none());
782 }
783
784 #[test]
785 fn parse_amz_target_cloudwatch_json() {
786 let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
788 assert_eq!(result.service, "monitoring");
789 assert_eq!(result.action, "PutMetricData");
790 assert_eq!(result.protocol, AwsProtocol::Json);
791 }
792
793 #[test]
794 fn flatten_json_to_query_nested() {
795 let body = Bytes::from(
796 serde_json::json!({
797 "Namespace": "MyApp",
798 "MetricData": [{
799 "MetricName": "Latency",
800 "Value": 12.5,
801 "StatisticValues": {"SampleCount": 3, "Sum": 10},
802 "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
803 }]
804 })
805 .to_string(),
806 );
807 let flat = flatten_json_to_query(&body);
808 assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
809 assert_eq!(
810 flat.get("MetricData.member.1.MetricName").unwrap(),
811 "Latency"
812 );
813 assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
814 assert_eq!(
815 flat.get("MetricData.member.1.StatisticValues.SampleCount")
816 .unwrap(),
817 "3"
818 );
819 assert_eq!(
820 flat.get("MetricData.member.1.Dimensions.member.1.Name")
821 .unwrap(),
822 "Endpoint"
823 );
824 assert_eq!(
825 flat.get("MetricData.member.1.Dimensions.member.1.Value")
826 .unwrap(),
827 "/api"
828 );
829 }
830
831 #[test]
832 fn flatten_json_to_query_non_object_is_empty() {
833 assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
834 assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
835 }
836
837 #[test]
838 fn parse_amz_target_various_prefixes() {
839 assert_eq!(
840 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
841 "sqs"
842 );
843 assert_eq!(
844 parse_amz_target("AmazonSNS.Publish").unwrap().service,
845 "sns"
846 );
847 assert_eq!(
848 parse_amz_target("DynamoDB_20120810.GetItem")
849 .unwrap()
850 .service,
851 "dynamodb"
852 );
853 assert_eq!(
854 parse_amz_target("Logs_20140328.PutLogEvents")
855 .unwrap()
856 .service,
857 "logs"
858 );
859 assert_eq!(
860 parse_amz_target("secretsmanager.GetSecretValue")
861 .unwrap()
862 .service,
863 "secretsmanager"
864 );
865 assert_eq!(
866 parse_amz_target("TrentService.Encrypt").unwrap().service,
867 "kms"
868 );
869 assert_eq!(
870 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
871 .unwrap()
872 .service,
873 "cognito-idp"
874 );
875 assert_eq!(
876 parse_amz_target("AWSStepFunctions.StartExecution")
877 .unwrap()
878 .service,
879 "states"
880 );
881 assert_eq!(
882 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
883 .unwrap()
884 .service,
885 "organizations"
886 );
887 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
888 }
889
890 #[test]
891 fn infer_service_from_action_maps_sts() {
892 assert_eq!(
893 infer_service_from_action("AssumeRole").as_deref(),
894 Some("sts")
895 );
896 assert_eq!(
897 infer_service_from_action("GetCallerIdentity").as_deref(),
898 Some("sts")
899 );
900 }
901
902 #[test]
903 fn infer_service_from_action_maps_iam() {
904 assert_eq!(
905 infer_service_from_action("CreateUser").as_deref(),
906 Some("iam")
907 );
908 assert_eq!(
909 infer_service_from_action("ListRoles").as_deref(),
910 Some("iam")
911 );
912 }
913
914 #[test]
915 fn infer_service_from_action_maps_ses() {
916 assert_eq!(
917 infer_service_from_action("SendEmail").as_deref(),
918 Some("ses")
919 );
920 assert_eq!(
921 infer_service_from_action("ListIdentities").as_deref(),
922 Some("ses")
923 );
924 }
925
926 #[test]
927 fn infer_service_from_action_maps_sns_confirmation_flow() {
928 assert_eq!(
931 infer_service_from_action("ConfirmSubscription").as_deref(),
932 Some("sns")
933 );
934 assert_eq!(
935 infer_service_from_action("Unsubscribe").as_deref(),
936 Some("sns")
937 );
938 }
939
940 #[test]
941 fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
942 let mut headers = HeaderMap::new();
945 headers.insert("host", "localhost:4566".parse().unwrap());
946 let mut query_params = HashMap::new();
947 query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
948 query_params.insert(
949 "TopicArn".to_string(),
950 "arn:aws:sns:us-east-1:000000000000:t".to_string(),
951 );
952 query_params.insert("Token".to_string(), "abc123".to_string());
953
954 let detected = detect_service(&headers, &query_params, &Bytes::new())
955 .expect("ConfirmSubscription must route to a service");
956 assert_eq!(detected.service, "sns");
957 assert_eq!(detected.action, "ConfirmSubscription");
958 assert_eq!(detected.protocol, AwsProtocol::Query);
959 }
960
961 #[test]
962 fn infer_service_from_action_unknown_returns_none() {
963 assert!(infer_service_from_action("NotARealAction").is_none());
964 }
965
966 #[test]
967 fn rest_protocol_for_returns_none_for_non_rest_service() {
968 assert!(rest_protocol_for("sqs").is_none());
969 }
970
971 #[test]
972 fn url_decode_handles_percent_and_plus() {
973 assert_eq!(url_decode("hello+world"), "hello world");
974 assert_eq!(url_decode("hello%20world"), "hello world");
975 assert_eq!(url_decode("100%25"), "100%");
976 }
977
978 #[test]
979 fn url_decode_ignores_malformed_percent() {
980 assert_eq!(url_decode("%ZZ"), "");
981 }
982
983 #[test]
984 fn from_hex_valid_digits() {
985 assert_eq!(from_hex(b'0'), Some(0));
986 assert_eq!(from_hex(b'9'), Some(9));
987 assert_eq!(from_hex(b'a'), Some(10));
988 assert_eq!(from_hex(b'F'), Some(15));
989 }
990
991 #[test]
992 fn from_hex_invalid_returns_none() {
993 assert!(from_hex(b'g').is_none());
994 assert!(from_hex(b' ').is_none());
995 }
996
997 #[test]
998 fn detect_service_via_amz_target() {
999 let mut headers = HeaderMap::new();
1000 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1001 let query = HashMap::new();
1002 let body = Bytes::new();
1003 let detected = detect_service(&headers, &query, &body).unwrap();
1004 assert_eq!(detected.service, "ssm");
1005 assert_eq!(detected.action, "GetParameter");
1006 }
1007
1008 #[test]
1009 fn detect_service_via_query_action_with_inferred_service() {
1010 let headers = HeaderMap::new();
1011 let mut query = HashMap::new();
1012 query.insert("Action".to_string(), "AssumeRole".to_string());
1013 let body = Bytes::new();
1014 let detected = detect_service(&headers, &query, &body).unwrap();
1015 assert_eq!(detected.service, "sts");
1016 assert_eq!(detected.action, "AssumeRole");
1017 assert_eq!(detected.protocol, AwsProtocol::Query);
1018 }
1019
1020 #[test]
1021 fn detect_service_via_form_body() {
1022 let headers = HeaderMap::new();
1023 let query = HashMap::new();
1024 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1025 let detected = detect_service(&headers, &query, &body).unwrap();
1026 assert_eq!(detected.service, "ses");
1027 assert_eq!(detected.action, "SendEmail");
1028 }
1029
1030 #[test]
1031 fn detect_service_via_sigv2_presigned() {
1032 let headers = HeaderMap::new();
1033 let mut query = HashMap::new();
1034 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1035 query.insert("Signature".to_string(), "sig".to_string());
1036 query.insert("Expires".to_string(), "1234567890".to_string());
1037 let body = Bytes::new();
1038 let detected = detect_service(&headers, &query, &body).unwrap();
1039 assert_eq!(detected.service, "s3");
1040 assert_eq!(detected.protocol, AwsProtocol::Rest);
1041 }
1042
1043 #[test]
1044 fn detect_service_via_sigv4_presigned_credential() {
1045 let headers = HeaderMap::new();
1046 let mut query = HashMap::new();
1047 query.insert(
1048 "X-Amz-Credential".to_string(),
1049 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1050 );
1051 let body = Bytes::new();
1052 let detected = detect_service(&headers, &query, &body).unwrap();
1053 assert_eq!(detected.service, "s3");
1054 assert_eq!(detected.protocol, AwsProtocol::Rest);
1055 }
1056
1057 #[test]
1058 fn detect_service_unknown_returns_none() {
1059 let headers = HeaderMap::new();
1060 let query = HashMap::new();
1061 let body = Bytes::new();
1062 assert!(detect_service(&headers, &query, &body).is_none());
1063 }
1064
1065 #[test]
1066 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1067 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1072 }
1073
1074 #[test]
1075 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1076 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1081 }
1082
1083 #[test]
1084 fn normalize_service_name_passes_through_unaliased_services() {
1085 assert_eq!(normalize_service_name("bedrock"), "bedrock");
1089 assert_eq!(normalize_service_name("s3"), "s3");
1090 assert_eq!(normalize_service_name("lambda"), "lambda");
1091 assert_eq!(normalize_service_name(""), "");
1092 assert_eq!(
1093 normalize_service_name("unknown-future-service"),
1094 "unknown-future-service"
1095 );
1096 }
1097
1098 #[test]
1099 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1100 let mut headers = HeaderMap::new();
1105 headers.insert(
1106 "authorization",
1107 "AWS4-HMAC-SHA256 \
1108 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1109 SignedHeaders=host, Signature=abc"
1110 .parse()
1111 .unwrap(),
1112 );
1113 let query = HashMap::new();
1114 let body = Bytes::new();
1115 let detected = detect_service(&headers, &query, &body).unwrap();
1116 assert_eq!(detected.service, "bedrock");
1117 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1118 }
1119
1120 #[test]
1121 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1122 let headers = HeaderMap::new();
1126 let mut query = HashMap::new();
1127 query.insert(
1128 "X-Amz-Credential".to_string(),
1129 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1130 );
1131 let body = Bytes::new();
1132 let detected = detect_service(&headers, &query, &body).unwrap();
1133 assert_eq!(detected.service, "bedrock");
1134 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1135 }
1136
1137 #[test]
1138 fn parse_routing_host_localstack_basic() {
1139 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1140 assert_eq!(h.service, "sqs");
1141 assert_eq!(h.region, "us-east-1");
1142 assert!(h.bucket.is_none());
1143 }
1144
1145 #[test]
1146 fn parse_routing_host_localstack_with_port() {
1147 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1148 assert_eq!(h.service, "lambda");
1149 assert_eq!(h.region, "eu-west-1");
1150 assert!(h.bucket.is_none());
1151 }
1152
1153 #[test]
1154 fn parse_routing_host_case_insensitive() {
1155 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1156 assert_eq!(h.service, "sqs");
1157 assert_eq!(h.region, "us-east-1");
1158
1159 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1160 assert_eq!(h.service, "lambda");
1161 assert_eq!(h.region, "us-east-1");
1162 }
1163
1164 #[test]
1165 fn parse_routing_host_localstack_s3_virtual_hosted() {
1166 let h =
1167 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1168 assert_eq!(h.service, "s3");
1169 assert_eq!(h.region, "us-east-1");
1170 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1171 }
1172
1173 #[test]
1174 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1175 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1176 assert_eq!(h.service, "s3");
1177 assert_eq!(h.region, "us-east-1");
1178 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1179 }
1180
1181 #[test]
1182 fn parse_routing_host_aws_service_region() {
1183 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1184 assert_eq!(h.service, "sqs");
1185 assert_eq!(h.region, "us-east-1");
1186 assert!(h.bucket.is_none());
1187
1188 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1189 assert_eq!(h.service, "dynamodb");
1190 assert_eq!(h.region, "eu-west-2");
1191 }
1192
1193 #[test]
1194 fn parse_routing_host_aws_s3_path_style_modern() {
1195 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1196 assert_eq!(h.service, "s3");
1197 assert_eq!(h.region, "us-east-1");
1198 assert!(h.bucket.is_none());
1199 }
1200
1201 #[test]
1202 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1203 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1204 assert_eq!(h.service, "s3");
1205 assert_eq!(h.region, "us-east-1");
1206 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1207 }
1208
1209 #[test]
1210 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1211 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1212 assert_eq!(h.service, "s3");
1213 assert_eq!(h.region, "us-east-1");
1214 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1215 }
1216
1217 #[test]
1218 fn parse_routing_host_aws_s3_legacy_global() {
1219 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1222 assert_eq!(h.service, "s3");
1223 assert_eq!(h.region, "us-east-1");
1224 assert!(h.bucket.is_none());
1225
1226 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1227 assert_eq!(h.service, "s3");
1228 assert_eq!(h.region, "us-east-1");
1229 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1230 }
1231
1232 #[test]
1233 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1234 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1237 assert_eq!(h.service, "s3");
1238 assert_eq!(h.region, "us-east-1");
1239 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1240 }
1241
1242 #[test]
1243 fn parse_routing_host_aws_s3_dash_separated() {
1244 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1246 assert_eq!(h.service, "s3");
1247 assert_eq!(h.region, "us-west-2");
1248 assert!(h.bucket.is_none());
1249
1250 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1251 assert_eq!(h.service, "s3");
1252 assert_eq!(h.region, "us-west-2");
1253 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1254 }
1255
1256 #[test]
1257 fn parse_routing_host_rejects_plain_localhost() {
1258 assert!(parse_routing_host("localhost:4566").is_none());
1259 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1260 }
1261
1262 #[test]
1263 fn parse_routing_host_rejects_unknown_suffix() {
1264 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1265 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1266 }
1267
1268 #[test]
1269 fn parse_routing_host_empty_and_malformed_rejected() {
1270 assert!(parse_routing_host("").is_none());
1271 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1272 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1273 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1274 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1275 assert!(parse_routing_host(".amazonaws.com").is_none());
1276 assert!(parse_routing_host("amazonaws.com").is_none());
1277 }
1278
1279 #[test]
1280 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1281 assert!(parse_routing_host("s3-accesspoint").is_none());
1285 }
1286
1287 #[test]
1288 fn detect_service_via_host_for_rest_service() {
1289 let mut headers = HeaderMap::new();
1290 headers.insert(
1291 "host",
1292 "s3.us-east-1.localhost.localstack.cloud:4566"
1293 .parse()
1294 .unwrap(),
1295 );
1296 let query = HashMap::new();
1297 let body = Bytes::new();
1298 let detected = detect_service(&headers, &query, &body).unwrap();
1299 assert_eq!(detected.service, "s3");
1300 assert_eq!(detected.protocol, AwsProtocol::Rest);
1301 }
1302
1303 #[test]
1304 fn detect_service_via_host_for_rest_json_service() {
1305 let mut headers = HeaderMap::new();
1306 headers.insert(
1307 "host",
1308 "lambda.us-east-1.localhost.localstack.cloud:4566"
1309 .parse()
1310 .unwrap(),
1311 );
1312 let query = HashMap::new();
1313 let body = Bytes::new();
1314 let detected = detect_service(&headers, &query, &body).unwrap();
1315 assert_eq!(detected.service, "lambda");
1316 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1317 }
1318
1319 #[test]
1320 fn detect_service_via_host_plus_query_action() {
1321 let mut headers = HeaderMap::new();
1322 headers.insert(
1323 "host",
1324 "sqs.us-east-1.localhost.localstack.cloud:4566"
1325 .parse()
1326 .unwrap(),
1327 );
1328 let mut query = HashMap::new();
1329 query.insert("Action".to_string(), "ListQueues".to_string());
1330 let body = Bytes::new();
1331 let detected = detect_service(&headers, &query, &body).unwrap();
1332 assert_eq!(detected.service, "sqs");
1333 assert_eq!(detected.action, "ListQueues");
1334 assert_eq!(detected.protocol, AwsProtocol::Query);
1335 }
1336
1337 #[test]
1338 fn detect_service_sigv4_wins_over_host() {
1339 let mut headers = HeaderMap::new();
1340 headers.insert(
1341 "authorization",
1342 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1343 SignedHeaders=host, Signature=abc"
1344 .parse()
1345 .unwrap(),
1346 );
1347 headers.insert(
1348 "host",
1349 "lambda.us-east-1.localhost.localstack.cloud:4566"
1350 .parse()
1351 .unwrap(),
1352 );
1353 let query = HashMap::new();
1354 let body = Bytes::new();
1355 let detected = detect_service(&headers, &query, &body).unwrap();
1356 assert_eq!(detected.service, "s3");
1358 assert_eq!(detected.protocol, AwsProtocol::Rest);
1359 }
1360
1361 #[test]
1362 fn detect_service_host_for_virtual_hosted_s3() {
1363 let mut headers = HeaderMap::new();
1364 headers.insert(
1365 "host",
1366 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1367 .parse()
1368 .unwrap(),
1369 );
1370 let query = HashMap::new();
1371 let body = Bytes::new();
1372 let detected = detect_service(&headers, &query, &body).unwrap();
1373 assert_eq!(detected.service, "s3");
1374 assert_eq!(detected.protocol, AwsProtocol::Rest);
1375 }
1376}