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 "glacier",
41 "backup",
42 "ram",
45 "s3tables",
48 "lakeformation",
51 "es",
55 "account",
56 "appconfig",
60 "codeartifact",
63];
64
65#[derive(Debug, Clone)]
67pub struct DetectedRequest {
68 pub service: String,
69 pub action: String,
70 pub protocol: AwsProtocol,
71}
72
73pub fn detect_service_headers_only(
80 headers: &HeaderMap,
81 query_params: &HashMap<String, String>,
82) -> Option<DetectedRequest> {
83 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
85 return parse_amz_target(target);
86 }
87 if let Some(action) = query_params.get("Action") {
88 let service = extract_service_from_auth(headers)
89 .or_else(|| infer_service_from_action(action))
90 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
91 if let Some(service) = service {
92 return Some(DetectedRequest {
93 service,
94 action: action.clone(),
95 protocol: AwsProtocol::Query,
96 });
97 }
98 }
99 if let Some(service) = extract_service_from_auth(headers) {
100 if let Some(protocol) = rest_protocol_for(&service) {
101 return Some(DetectedRequest {
102 service,
103 action: String::new(),
104 protocol,
105 });
106 }
107 }
108 if let Some(credential) = query_params.get("X-Amz-Credential") {
109 let parts: Vec<&str> = credential.split('/').collect();
110 if parts.len() >= 4 {
111 let service = normalize_service_name(parts[3]).to_string();
112 if let Some(protocol) = rest_protocol_for(&service) {
113 return Some(DetectedRequest {
114 service,
115 action: String::new(),
116 protocol,
117 });
118 }
119 }
120 }
121 if query_params.contains_key("AWSAccessKeyId")
122 && query_params.contains_key("Signature")
123 && query_params.contains_key("Expires")
124 {
125 return Some(DetectedRequest {
126 service: "s3".to_string(),
127 action: String::new(),
128 protocol: AwsProtocol::Rest,
129 });
130 }
131 if let Some(host_info) = parse_routing_host_from_headers(headers) {
132 if let Some(protocol) = rest_protocol_for(&host_info.service) {
133 return Some(DetectedRequest {
134 service: host_info.service,
135 action: String::new(),
136 protocol,
137 });
138 }
139 }
140 None
141}
142
143pub fn detect_service(
145 headers: &HeaderMap,
146 query_params: &HashMap<String, String>,
147 body: &Bytes,
148) -> Option<DetectedRequest> {
149 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
151 return parse_amz_target(target);
152 }
153
154 if let Some(action) = query_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 {
170 let form_params = decode_form_urlencoded(body);
171
172 if let Some(action) = form_params.get("Action") {
173 let service = extract_service_from_auth(headers)
174 .or_else(|| infer_service_from_action(action))
175 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
176 if let Some(service) = service {
177 return Some(DetectedRequest {
178 service,
179 action: action.clone(),
180 protocol: AwsProtocol::Query,
181 });
182 }
183 }
184 }
185
186 if let Some(service) = extract_service_from_auth(headers) {
188 if let Some(protocol) = rest_protocol_for(&service) {
189 return Some(DetectedRequest {
190 service,
191 action: String::new(), protocol,
193 });
194 }
195 }
196
197 if let Some(credential) = query_params.get("X-Amz-Credential") {
199 let parts: Vec<&str> = credential.split('/').collect();
201 if parts.len() >= 4 {
202 let service = normalize_service_name(parts[3]).to_string();
203 if let Some(protocol) = rest_protocol_for(&service) {
204 return Some(DetectedRequest {
205 service,
206 action: String::new(),
207 protocol,
208 });
209 }
210 }
211 }
212
213 if query_params.contains_key("AWSAccessKeyId")
217 && query_params.contains_key("Signature")
218 && query_params.contains_key("Expires")
219 {
220 return Some(DetectedRequest {
221 service: "s3".to_string(),
222 action: String::new(),
223 protocol: AwsProtocol::Rest,
224 });
225 }
226
227 if let Some(host_info) = parse_routing_host_from_headers(headers) {
231 if let Some(protocol) = rest_protocol_for(&host_info.service) {
232 return Some(DetectedRequest {
233 service: host_info.service,
234 action: String::new(),
235 protocol,
236 });
237 }
238 }
239
240 None
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct RoutingHost {
253 pub service: String,
254 pub region: String,
255 pub bucket: Option<String>,
257}
258
259const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
260const AWS_SUFFIX: &str = ".amazonaws.com";
261
262pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
266 let hostname = host.split(':').next()?;
267 if hostname.is_empty() {
268 return None;
269 }
270 let hostname = hostname.to_ascii_lowercase();
271 if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
272 return parse_localstack_prefix(prefix);
273 }
274 if hostname == "amazonaws.com" {
275 return None;
276 }
277 if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
278 return parse_aws_prefix(prefix);
279 }
280 None
281}
282
283pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
285 let host = headers.get("host")?.to_str().ok()?;
286 parse_routing_host(host)
287}
288
289fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
290 if prefix.is_empty() {
291 return None;
292 }
293 let labels: Vec<&str> = prefix.split('.').collect();
294 if labels.iter().any(|l| l.is_empty()) {
295 return None;
296 }
297 match labels.len() {
298 2 => Some(RoutingHost {
299 service: labels[0].to_string(),
300 region: labels[1].to_string(),
301 bucket: None,
302 }),
303 n if n >= 3 && labels[n - 2] == "s3" => {
304 let bucket = labels[..n - 2].join(".");
305 Some(RoutingHost {
306 service: "s3".to_string(),
307 region: labels[n - 1].to_string(),
308 bucket: Some(bucket),
309 })
310 }
311 n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
312 let bucket = labels[..n - 2].join(".");
313 Some(RoutingHost {
314 service: "s3".to_string(),
315 region: labels[n - 1].to_string(),
316 bucket: Some(bucket),
317 })
318 }
319 n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
320 service: "s3".to_string(),
321 region: labels[n - 1].to_string(),
322 bucket: None,
323 }),
324 _ => None,
325 }
326}
327
328fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
340 if prefix.is_empty() {
341 return None;
342 }
343 let labels: Vec<&str> = prefix.split('.').collect();
344 if labels.iter().any(|l| l.is_empty()) {
345 return None;
346 }
347 let last = *labels.last()?;
348
349 if let Some(region) = last.strip_prefix("s3-") {
352 if !region.is_empty() {
353 let bucket = if labels.len() >= 2 {
354 Some(labels[..labels.len() - 1].join("."))
355 } else {
356 None
357 };
358 return Some(RoutingHost {
359 service: "s3".to_string(),
360 region: region.to_string(),
361 bucket,
362 });
363 }
364 }
365
366 if last == "s3" {
370 if labels.len() == 1 {
371 return Some(RoutingHost {
372 service: "s3".to_string(),
373 region: "us-east-1".to_string(),
374 bucket: None,
375 });
376 }
377 return Some(RoutingHost {
378 service: "s3".to_string(),
379 region: "us-east-1".to_string(),
380 bucket: Some(labels[..labels.len() - 1].join(".")),
381 });
382 }
383
384 if last == "s3-accesspoint" {
387 if labels.len() == 2 {
388 return Some(RoutingHost {
389 service: "s3".to_string(),
390 region: labels[0].to_string(),
391 bucket: None,
392 });
393 }
394 if labels.len() >= 3 {
398 let bucket = labels[..labels.len() - 2].join(".");
399 return Some(RoutingHost {
400 service: "s3".to_string(),
401 region: labels[labels.len() - 1].to_string(),
402 bucket: Some(bucket),
403 });
404 }
405 }
406
407 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
410 return Some(RoutingHost {
411 service: "s3".to_string(),
412 region: last.to_string(),
413 bucket: None,
414 });
415 }
416
417 match labels.len() {
418 2 => Some(RoutingHost {
421 service: labels[0].to_string(),
422 region: labels[1].to_string(),
423 bucket: None,
424 }),
425 n if n >= 3 && labels[n - 2] == "s3" => {
427 let bucket = labels[..n - 2].join(".");
428 Some(RoutingHost {
429 service: "s3".to_string(),
430 region: labels[n - 1].to_string(),
431 bucket: Some(bucket),
432 })
433 }
434 _ => None,
435 }
436}
437
438fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
441 let (prefix, action) = target.rsplit_once('.')?;
442
443 let service = match prefix {
444 "AWSEvents" => "events",
445 "AmazonSSM" => "ssm",
446 "AmazonSQS" => "sqs",
447 "AmazonSNS" => "sns",
448 "DynamoDB_20120810" => "dynamodb",
449 "DynamoDBStreams_20120810" => "dynamodbstreams",
450 "Logs_20140328" => "logs",
451 s if s.starts_with("secretsmanager") => "secretsmanager",
452 s if s.starts_with("TrentService") => "kms",
453 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
454 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
455 s if s.starts_with("Kinesis_20131202") => "kinesis",
456 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
457 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
458 s if s.starts_with("AWSStepFunctions") => "states",
459 s if s.starts_with("AWSOrganizationsV") => "organizations",
460 "CertificateManager" => "acm",
461 "AnyScaleFrontendService" => "application-autoscaling",
462 "AWSWAF_20190729" => "wafv2",
465 "AmazonAthena" => "athena",
466 s if s.starts_with("Firehose_") => "firehose",
467 "AWSGlue" => "glue",
468 "CloudApiService" => "cloudcontrolapi",
469 "ResourceGroupsTaggingAPI_20170126" => "tagging",
470 "AmazonMemoryDB" => "memorydb",
471 "Route53AutoNaming_v20170314" => "servicediscovery",
474 "AmazonDMSv20160101" => "dms",
476 "CloudTrail_20131101" => "cloudtrail",
480 "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
481 "AWSInsightsIndexService" => "ce",
485 "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
486 "TransferService" => "transfer",
488 "CodeBuild_20161006" => "codebuild",
490 "CodeCommit_20150413" => "codecommit",
492 "AWSIdentityStore" => "identitystore",
494 "SWBExternalService" => "sso",
496 "VerifiedPermissions" => "verifiedpermissions",
498 "CodeConnections_20231201" => "codeconnections",
500 "CodeStar_connections_20191201" => "codeconnections",
506 "CodeDeploy_20141006" => "codedeploy",
508 "CodePipeline_20150709" => "codepipeline",
510 s if s.starts_with("GraniteServiceVersion") => "monitoring",
516 _ => return None,
517 };
518
519 Some(DetectedRequest {
520 service: service.to_string(),
521 action: action.to_string(),
522 protocol: AwsProtocol::Json,
523 })
524}
525
526fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
528 if REST_XML_SERVICES.contains(&service) {
529 Some(AwsProtocol::Rest)
530 } else if REST_JSON_SERVICES.contains(&service) {
531 Some(AwsProtocol::RestJson)
532 } else {
533 None
534 }
535}
536
537fn infer_service_from_action(action: &str) -> Option<String> {
541 match action {
542 "AssumeRole"
543 | "AssumeRoleWithSAML"
544 | "AssumeRoleWithWebIdentity"
545 | "GetCallerIdentity"
546 | "GetSessionToken"
547 | "GetFederationToken"
548 | "GetAccessKeyInfo"
549 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
550 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
551 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
552 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
553 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
554 "VerifyEmailIdentity"
556 | "VerifyDomainIdentity"
557 | "VerifyDomainDkim"
558 | "ListIdentities"
559 | "GetIdentityVerificationAttributes"
560 | "GetIdentityDkimAttributes"
561 | "DeleteIdentity"
562 | "SetIdentityDkimEnabled"
563 | "SetIdentityNotificationTopic"
564 | "SetIdentityFeedbackForwardingEnabled"
565 | "GetIdentityNotificationAttributes"
566 | "GetIdentityMailFromDomainAttributes"
567 | "SetIdentityMailFromDomain"
568 | "SendEmail"
569 | "SendRawEmail"
570 | "SendTemplatedEmail"
571 | "SendBulkTemplatedEmail"
572 | "CreateTemplate"
573 | "GetTemplate"
574 | "ListTemplates"
575 | "DeleteTemplate"
576 | "UpdateTemplate"
577 | "CreateConfigurationSet"
578 | "DeleteConfigurationSet"
579 | "DescribeConfigurationSet"
580 | "ListConfigurationSets"
581 | "CreateConfigurationSetEventDestination"
582 | "UpdateConfigurationSetEventDestination"
583 | "DeleteConfigurationSetEventDestination"
584 | "GetSendQuota"
585 | "GetSendStatistics"
586 | "GetAccountSendingEnabled"
587 | "CreateReceiptRuleSet"
588 | "DeleteReceiptRuleSet"
589 | "DescribeReceiptRuleSet"
590 | "ListReceiptRuleSets"
591 | "CloneReceiptRuleSet"
592 | "SetActiveReceiptRuleSet"
593 | "ReorderReceiptRuleSet"
594 | "CreateReceiptRule"
595 | "DeleteReceiptRule"
596 | "DescribeReceiptRule"
597 | "UpdateReceiptRule"
598 | "CreateReceiptFilter"
599 | "DeleteReceiptFilter"
600 | "ListReceiptFilters" => Some("ses".to_string()),
601 "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
605 _ => None,
606 }
607}
608
609fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
611 let auth = headers.get("authorization")?.to_str().ok()?;
612 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
613 Some(normalize_service_name(&info.service).to_string())
614}
615
616fn normalize_service_name(service: &str) -> &str {
628 match service {
629 "bedrock-runtime" => "bedrock",
630 "apigatewayv2" => "apigateway",
638 "opensearch" => "es",
646 "appconfigdata" => "appconfig",
652 other => other,
653 }
654}
655
656pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
658 decode_form_urlencoded(body)
659}
660
661pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
678 let mut out = HashMap::new();
679 let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
680 return out;
681 };
682 if value.is_object() {
683 flatten_json_value("", &value, &mut out);
684 }
685 out
686}
687
688fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
689 match value {
690 serde_json::Value::Object(map) => {
691 for (k, v) in map {
692 let child = if prefix.is_empty() {
693 k.clone()
694 } else {
695 format!("{prefix}.{k}")
696 };
697 flatten_json_value(&child, v, out);
698 }
699 }
700 serde_json::Value::Array(items) => {
701 for (i, v) in items.iter().enumerate() {
702 let child = format!("{prefix}.member.{}", i + 1);
703 flatten_json_value(&child, v, out);
704 }
705 }
706 serde_json::Value::Null => {}
707 serde_json::Value::String(s) => {
708 out.insert(prefix.to_string(), s.clone());
709 }
710 serde_json::Value::Bool(b) => {
711 out.insert(prefix.to_string(), b.to_string());
712 }
713 serde_json::Value::Number(n) => {
714 out.insert(prefix.to_string(), n.to_string());
715 }
716 }
717}
718
719fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
720 let s = std::str::from_utf8(input).unwrap_or("");
721 let mut result = HashMap::new();
722 for pair in s.split('&') {
723 if pair.is_empty() {
724 continue;
725 }
726 let (key, value) = match pair.find('=') {
727 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
728 None => (pair, ""),
729 };
730 result.insert(url_decode(key), url_decode(value));
731 }
732 result
733}
734
735fn url_decode(input: &str) -> String {
736 let mut result = String::with_capacity(input.len());
737 let mut bytes = input.bytes();
738 while let Some(b) = bytes.next() {
739 match b {
740 b'+' => result.push(' '),
741 b'%' => {
742 let high = bytes.next().and_then(from_hex);
743 let low = bytes.next().and_then(from_hex);
744 if let (Some(h), Some(l)) = (high, low) {
745 result.push((h << 4 | l) as char);
746 }
747 }
748 _ => result.push(b as char),
749 }
750 }
751 result
752}
753
754fn from_hex(b: u8) -> Option<u8> {
755 match b {
756 b'0'..=b'9' => Some(b - b'0'),
757 b'a'..=b'f' => Some(b - b'a' + 10),
758 b'A'..=b'F' => Some(b - b'A' + 10),
759 _ => None,
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn parse_amz_target_events() {
769 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
770 assert_eq!(result.service, "events");
771 assert_eq!(result.action, "PutEvents");
772 assert_eq!(result.protocol, AwsProtocol::Json);
773 }
774
775 #[test]
776 fn parse_amz_target_ssm() {
777 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
778 assert_eq!(result.service, "ssm");
779 assert_eq!(result.action, "GetParameter");
780 }
781
782 #[test]
783 fn parse_amz_target_kinesis() {
784 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
785 assert_eq!(result.service, "kinesis");
786 assert_eq!(result.action, "ListStreams");
787 assert_eq!(result.protocol, AwsProtocol::Json);
788 }
789
790 #[test]
791 fn parse_query_body_basic() {
792 let body = Bytes::from(
793 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
794 );
795 let params = parse_query_body(&body);
796 assert_eq!(params.get("Action").unwrap(), "SendMessage");
797 assert_eq!(params.get("MessageBody").unwrap(), "hello");
798 }
799
800 #[test]
801 fn parse_query_body_empty_returns_empty_map() {
802 let body = Bytes::from("");
803 let params = parse_query_body(&body);
804 assert!(params.is_empty());
805 }
806
807 #[test]
808 fn parse_query_body_duplicate_keys_last_wins() {
809 let body = Bytes::from("key=a&key=b");
810 let params = parse_query_body(&body);
811 assert_eq!(params.get("key").unwrap(), "b");
812 }
813
814 #[test]
815 fn parse_query_body_single_key() {
816 let body = Bytes::from("key=value");
817 let params = parse_query_body(&body);
818 assert_eq!(params.get("key").unwrap(), "value");
819 }
820
821 #[test]
822 fn parse_amz_target_ecs() {
823 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
824 assert_eq!(result.service, "ecs");
825 assert_eq!(result.action, "ListClusters");
826 assert_eq!(result.protocol, AwsProtocol::Json);
827 }
828
829 #[test]
830 fn parse_amz_target_invalid_returns_none() {
831 assert!(parse_amz_target("NoDotHere").is_none());
832 assert!(parse_amz_target("").is_none());
833 }
834
835 #[test]
836 fn parse_amz_target_cloudwatch_json() {
837 let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
839 assert_eq!(result.service, "monitoring");
840 assert_eq!(result.action, "PutMetricData");
841 assert_eq!(result.protocol, AwsProtocol::Json);
842 }
843
844 #[test]
845 fn flatten_json_to_query_nested() {
846 let body = Bytes::from(
847 serde_json::json!({
848 "Namespace": "MyApp",
849 "MetricData": [{
850 "MetricName": "Latency",
851 "Value": 12.5,
852 "StatisticValues": {"SampleCount": 3, "Sum": 10},
853 "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
854 }]
855 })
856 .to_string(),
857 );
858 let flat = flatten_json_to_query(&body);
859 assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
860 assert_eq!(
861 flat.get("MetricData.member.1.MetricName").unwrap(),
862 "Latency"
863 );
864 assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
865 assert_eq!(
866 flat.get("MetricData.member.1.StatisticValues.SampleCount")
867 .unwrap(),
868 "3"
869 );
870 assert_eq!(
871 flat.get("MetricData.member.1.Dimensions.member.1.Name")
872 .unwrap(),
873 "Endpoint"
874 );
875 assert_eq!(
876 flat.get("MetricData.member.1.Dimensions.member.1.Value")
877 .unwrap(),
878 "/api"
879 );
880 }
881
882 #[test]
883 fn flatten_json_to_query_non_object_is_empty() {
884 assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
885 assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
886 }
887
888 #[test]
889 fn parse_amz_target_various_prefixes() {
890 assert_eq!(
891 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
892 "sqs"
893 );
894 assert_eq!(
895 parse_amz_target("AmazonSNS.Publish").unwrap().service,
896 "sns"
897 );
898 assert_eq!(
899 parse_amz_target("DynamoDB_20120810.GetItem")
900 .unwrap()
901 .service,
902 "dynamodb"
903 );
904 assert_eq!(
905 parse_amz_target("Logs_20140328.PutLogEvents")
906 .unwrap()
907 .service,
908 "logs"
909 );
910 assert_eq!(
911 parse_amz_target("secretsmanager.GetSecretValue")
912 .unwrap()
913 .service,
914 "secretsmanager"
915 );
916 assert_eq!(
917 parse_amz_target("TrentService.Encrypt").unwrap().service,
918 "kms"
919 );
920 assert_eq!(
921 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
922 .unwrap()
923 .service,
924 "cognito-idp"
925 );
926 assert_eq!(
927 parse_amz_target("AWSStepFunctions.StartExecution")
928 .unwrap()
929 .service,
930 "states"
931 );
932 assert_eq!(
933 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
934 .unwrap()
935 .service,
936 "organizations"
937 );
938 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
939 }
940
941 #[test]
942 fn infer_service_from_action_maps_sts() {
943 assert_eq!(
944 infer_service_from_action("AssumeRole").as_deref(),
945 Some("sts")
946 );
947 assert_eq!(
948 infer_service_from_action("GetCallerIdentity").as_deref(),
949 Some("sts")
950 );
951 }
952
953 #[test]
954 fn infer_service_from_action_maps_iam() {
955 assert_eq!(
956 infer_service_from_action("CreateUser").as_deref(),
957 Some("iam")
958 );
959 assert_eq!(
960 infer_service_from_action("ListRoles").as_deref(),
961 Some("iam")
962 );
963 }
964
965 #[test]
966 fn infer_service_from_action_maps_ses() {
967 assert_eq!(
968 infer_service_from_action("SendEmail").as_deref(),
969 Some("ses")
970 );
971 assert_eq!(
972 infer_service_from_action("ListIdentities").as_deref(),
973 Some("ses")
974 );
975 }
976
977 #[test]
978 fn infer_service_from_action_maps_sns_confirmation_flow() {
979 assert_eq!(
982 infer_service_from_action("ConfirmSubscription").as_deref(),
983 Some("sns")
984 );
985 assert_eq!(
986 infer_service_from_action("Unsubscribe").as_deref(),
987 Some("sns")
988 );
989 }
990
991 #[test]
992 fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
993 let mut headers = HeaderMap::new();
996 headers.insert("host", "localhost:4566".parse().unwrap());
997 let mut query_params = HashMap::new();
998 query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
999 query_params.insert(
1000 "TopicArn".to_string(),
1001 "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1002 );
1003 query_params.insert("Token".to_string(), "abc123".to_string());
1004
1005 let detected = detect_service(&headers, &query_params, &Bytes::new())
1006 .expect("ConfirmSubscription must route to a service");
1007 assert_eq!(detected.service, "sns");
1008 assert_eq!(detected.action, "ConfirmSubscription");
1009 assert_eq!(detected.protocol, AwsProtocol::Query);
1010 }
1011
1012 #[test]
1013 fn infer_service_from_action_unknown_returns_none() {
1014 assert!(infer_service_from_action("NotARealAction").is_none());
1015 }
1016
1017 #[test]
1018 fn rest_protocol_for_returns_none_for_non_rest_service() {
1019 assert!(rest_protocol_for("sqs").is_none());
1020 }
1021
1022 #[test]
1023 fn url_decode_handles_percent_and_plus() {
1024 assert_eq!(url_decode("hello+world"), "hello world");
1025 assert_eq!(url_decode("hello%20world"), "hello world");
1026 assert_eq!(url_decode("100%25"), "100%");
1027 }
1028
1029 #[test]
1030 fn url_decode_ignores_malformed_percent() {
1031 assert_eq!(url_decode("%ZZ"), "");
1032 }
1033
1034 #[test]
1035 fn from_hex_valid_digits() {
1036 assert_eq!(from_hex(b'0'), Some(0));
1037 assert_eq!(from_hex(b'9'), Some(9));
1038 assert_eq!(from_hex(b'a'), Some(10));
1039 assert_eq!(from_hex(b'F'), Some(15));
1040 }
1041
1042 #[test]
1043 fn from_hex_invalid_returns_none() {
1044 assert!(from_hex(b'g').is_none());
1045 assert!(from_hex(b' ').is_none());
1046 }
1047
1048 #[test]
1049 fn detect_service_via_amz_target() {
1050 let mut headers = HeaderMap::new();
1051 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1052 let query = HashMap::new();
1053 let body = Bytes::new();
1054 let detected = detect_service(&headers, &query, &body).unwrap();
1055 assert_eq!(detected.service, "ssm");
1056 assert_eq!(detected.action, "GetParameter");
1057 }
1058
1059 #[test]
1060 fn detect_service_via_query_action_with_inferred_service() {
1061 let headers = HeaderMap::new();
1062 let mut query = HashMap::new();
1063 query.insert("Action".to_string(), "AssumeRole".to_string());
1064 let body = Bytes::new();
1065 let detected = detect_service(&headers, &query, &body).unwrap();
1066 assert_eq!(detected.service, "sts");
1067 assert_eq!(detected.action, "AssumeRole");
1068 assert_eq!(detected.protocol, AwsProtocol::Query);
1069 }
1070
1071 #[test]
1072 fn detect_service_via_form_body() {
1073 let headers = HeaderMap::new();
1074 let query = HashMap::new();
1075 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1076 let detected = detect_service(&headers, &query, &body).unwrap();
1077 assert_eq!(detected.service, "ses");
1078 assert_eq!(detected.action, "SendEmail");
1079 }
1080
1081 #[test]
1082 fn detect_service_via_sigv2_presigned() {
1083 let headers = HeaderMap::new();
1084 let mut query = HashMap::new();
1085 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1086 query.insert("Signature".to_string(), "sig".to_string());
1087 query.insert("Expires".to_string(), "1234567890".to_string());
1088 let body = Bytes::new();
1089 let detected = detect_service(&headers, &query, &body).unwrap();
1090 assert_eq!(detected.service, "s3");
1091 assert_eq!(detected.protocol, AwsProtocol::Rest);
1092 }
1093
1094 #[test]
1095 fn detect_service_via_sigv4_presigned_credential() {
1096 let headers = HeaderMap::new();
1097 let mut query = HashMap::new();
1098 query.insert(
1099 "X-Amz-Credential".to_string(),
1100 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1101 );
1102 let body = Bytes::new();
1103 let detected = detect_service(&headers, &query, &body).unwrap();
1104 assert_eq!(detected.service, "s3");
1105 assert_eq!(detected.protocol, AwsProtocol::Rest);
1106 }
1107
1108 #[test]
1109 fn detect_service_unknown_returns_none() {
1110 let headers = HeaderMap::new();
1111 let query = HashMap::new();
1112 let body = Bytes::new();
1113 assert!(detect_service(&headers, &query, &body).is_none());
1114 }
1115
1116 #[test]
1117 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1118 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1123 }
1124
1125 #[test]
1126 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1127 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1132 }
1133
1134 #[test]
1135 fn normalize_service_name_passes_through_unaliased_services() {
1136 assert_eq!(normalize_service_name("bedrock"), "bedrock");
1140 assert_eq!(normalize_service_name("s3"), "s3");
1141 assert_eq!(normalize_service_name("lambda"), "lambda");
1142 assert_eq!(normalize_service_name(""), "");
1143 assert_eq!(
1144 normalize_service_name("unknown-future-service"),
1145 "unknown-future-service"
1146 );
1147 }
1148
1149 #[test]
1150 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1151 let mut headers = HeaderMap::new();
1156 headers.insert(
1157 "authorization",
1158 "AWS4-HMAC-SHA256 \
1159 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1160 SignedHeaders=host, Signature=abc"
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, "bedrock");
1168 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1169 }
1170
1171 #[test]
1172 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1173 let headers = HeaderMap::new();
1177 let mut query = HashMap::new();
1178 query.insert(
1179 "X-Amz-Credential".to_string(),
1180 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1181 );
1182 let body = Bytes::new();
1183 let detected = detect_service(&headers, &query, &body).unwrap();
1184 assert_eq!(detected.service, "bedrock");
1185 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1186 }
1187
1188 #[test]
1189 fn parse_routing_host_localstack_basic() {
1190 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1191 assert_eq!(h.service, "sqs");
1192 assert_eq!(h.region, "us-east-1");
1193 assert!(h.bucket.is_none());
1194 }
1195
1196 #[test]
1197 fn parse_routing_host_localstack_with_port() {
1198 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1199 assert_eq!(h.service, "lambda");
1200 assert_eq!(h.region, "eu-west-1");
1201 assert!(h.bucket.is_none());
1202 }
1203
1204 #[test]
1205 fn parse_routing_host_case_insensitive() {
1206 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1207 assert_eq!(h.service, "sqs");
1208 assert_eq!(h.region, "us-east-1");
1209
1210 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1211 assert_eq!(h.service, "lambda");
1212 assert_eq!(h.region, "us-east-1");
1213 }
1214
1215 #[test]
1216 fn parse_routing_host_localstack_s3_virtual_hosted() {
1217 let h =
1218 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1219 assert_eq!(h.service, "s3");
1220 assert_eq!(h.region, "us-east-1");
1221 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1222 }
1223
1224 #[test]
1225 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1226 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1227 assert_eq!(h.service, "s3");
1228 assert_eq!(h.region, "us-east-1");
1229 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1230 }
1231
1232 #[test]
1233 fn parse_routing_host_aws_service_region() {
1234 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1235 assert_eq!(h.service, "sqs");
1236 assert_eq!(h.region, "us-east-1");
1237 assert!(h.bucket.is_none());
1238
1239 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1240 assert_eq!(h.service, "dynamodb");
1241 assert_eq!(h.region, "eu-west-2");
1242 }
1243
1244 #[test]
1245 fn parse_routing_host_aws_s3_path_style_modern() {
1246 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1247 assert_eq!(h.service, "s3");
1248 assert_eq!(h.region, "us-east-1");
1249 assert!(h.bucket.is_none());
1250 }
1251
1252 #[test]
1253 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1254 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1255 assert_eq!(h.service, "s3");
1256 assert_eq!(h.region, "us-east-1");
1257 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1258 }
1259
1260 #[test]
1261 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1262 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1263 assert_eq!(h.service, "s3");
1264 assert_eq!(h.region, "us-east-1");
1265 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1266 }
1267
1268 #[test]
1269 fn parse_routing_host_aws_s3_legacy_global() {
1270 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1273 assert_eq!(h.service, "s3");
1274 assert_eq!(h.region, "us-east-1");
1275 assert!(h.bucket.is_none());
1276
1277 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1278 assert_eq!(h.service, "s3");
1279 assert_eq!(h.region, "us-east-1");
1280 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1281 }
1282
1283 #[test]
1284 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1285 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1288 assert_eq!(h.service, "s3");
1289 assert_eq!(h.region, "us-east-1");
1290 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1291 }
1292
1293 #[test]
1294 fn parse_routing_host_aws_s3_dash_separated() {
1295 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1297 assert_eq!(h.service, "s3");
1298 assert_eq!(h.region, "us-west-2");
1299 assert!(h.bucket.is_none());
1300
1301 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1302 assert_eq!(h.service, "s3");
1303 assert_eq!(h.region, "us-west-2");
1304 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1305 }
1306
1307 #[test]
1308 fn parse_routing_host_rejects_plain_localhost() {
1309 assert!(parse_routing_host("localhost:4566").is_none());
1310 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1311 }
1312
1313 #[test]
1314 fn parse_routing_host_rejects_unknown_suffix() {
1315 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1316 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1317 }
1318
1319 #[test]
1320 fn parse_routing_host_empty_and_malformed_rejected() {
1321 assert!(parse_routing_host("").is_none());
1322 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1323 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1324 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1325 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1326 assert!(parse_routing_host(".amazonaws.com").is_none());
1327 assert!(parse_routing_host("amazonaws.com").is_none());
1328 }
1329
1330 #[test]
1331 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1332 assert!(parse_routing_host("s3-accesspoint").is_none());
1336 }
1337
1338 #[test]
1339 fn detect_service_via_host_for_rest_service() {
1340 let mut headers = HeaderMap::new();
1341 headers.insert(
1342 "host",
1343 "s3.us-east-1.localhost.localstack.cloud:4566"
1344 .parse()
1345 .unwrap(),
1346 );
1347 let query = HashMap::new();
1348 let body = Bytes::new();
1349 let detected = detect_service(&headers, &query, &body).unwrap();
1350 assert_eq!(detected.service, "s3");
1351 assert_eq!(detected.protocol, AwsProtocol::Rest);
1352 }
1353
1354 #[test]
1355 fn detect_service_via_host_for_rest_json_service() {
1356 let mut headers = HeaderMap::new();
1357 headers.insert(
1358 "host",
1359 "lambda.us-east-1.localhost.localstack.cloud:4566"
1360 .parse()
1361 .unwrap(),
1362 );
1363 let query = HashMap::new();
1364 let body = Bytes::new();
1365 let detected = detect_service(&headers, &query, &body).unwrap();
1366 assert_eq!(detected.service, "lambda");
1367 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1368 }
1369
1370 #[test]
1371 fn detect_service_via_host_plus_query_action() {
1372 let mut headers = HeaderMap::new();
1373 headers.insert(
1374 "host",
1375 "sqs.us-east-1.localhost.localstack.cloud:4566"
1376 .parse()
1377 .unwrap(),
1378 );
1379 let mut query = HashMap::new();
1380 query.insert("Action".to_string(), "ListQueues".to_string());
1381 let body = Bytes::new();
1382 let detected = detect_service(&headers, &query, &body).unwrap();
1383 assert_eq!(detected.service, "sqs");
1384 assert_eq!(detected.action, "ListQueues");
1385 assert_eq!(detected.protocol, AwsProtocol::Query);
1386 }
1387
1388 #[test]
1389 fn detect_service_sigv4_wins_over_host() {
1390 let mut headers = HeaderMap::new();
1391 headers.insert(
1392 "authorization",
1393 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1394 SignedHeaders=host, Signature=abc"
1395 .parse()
1396 .unwrap(),
1397 );
1398 headers.insert(
1399 "host",
1400 "lambda.us-east-1.localhost.localstack.cloud:4566"
1401 .parse()
1402 .unwrap(),
1403 );
1404 let query = HashMap::new();
1405 let body = Bytes::new();
1406 let detected = detect_service(&headers, &query, &body).unwrap();
1407 assert_eq!(detected.service, "s3");
1409 assert_eq!(detected.protocol, AwsProtocol::Rest);
1410 }
1411
1412 #[test]
1413 fn detect_service_host_for_virtual_hosted_s3() {
1414 let mut headers = HeaderMap::new();
1415 headers.insert(
1416 "host",
1417 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1418 .parse()
1419 .unwrap(),
1420 );
1421 let query = HashMap::new();
1422 let body = Bytes::new();
1423 let detected = detect_service(&headers, &query, &body).unwrap();
1424 assert_eq!(detected.service, "s3");
1425 assert_eq!(detected.protocol, AwsProtocol::Rest);
1426 }
1427}