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