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