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 "ACMPrivateCA" => "acm-pca",
469 "Route53Resolver" => "route53resolver",
473 "StarlingDoveService" => "config",
476 "AnyScaleFrontendService" => "application-autoscaling",
477 "AWSWAF_20190729" => "wafv2",
480 "AmazonAthena" => "athena",
481 s if s.starts_with("Firehose_") => "firehose",
482 "AWSGlue" => "glue",
483 "CloudApiService" => "cloudcontrolapi",
484 "ResourceGroupsTaggingAPI_20170126" => "tagging",
485 "AmazonMemoryDB" => "memorydb",
486 "Route53AutoNaming_v20170314" => "servicediscovery",
489 "AmazonDMSv20160101" => "dms",
491 "CloudTrail_20131101" => "cloudtrail",
495 "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
496 "AWSInsightsIndexService" => "ce",
500 "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
501 "TransferService" => "transfer",
503 "CodeBuild_20161006" => "codebuild",
505 "CodeCommit_20150413" => "codecommit",
507 "AWSIdentityStore" => "identitystore",
509 "SWBExternalService" => "sso",
511 "VerifiedPermissions" => "verifiedpermissions",
513 "CodeConnections_20231201" => "codeconnections",
515 "CodeStar_connections_20191201" => "codeconnections",
521 "CodeDeploy_20141006" => "codedeploy",
523 "CodePipeline_20150709" => "codepipeline",
525 s if s.starts_with("GraniteServiceVersion") => "monitoring",
531 _ => return None,
532 };
533
534 Some(DetectedRequest {
535 service: service.to_string(),
536 action: action.to_string(),
537 protocol: AwsProtocol::Json,
538 })
539}
540
541fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
543 if REST_XML_SERVICES.contains(&service) {
544 Some(AwsProtocol::Rest)
545 } else if REST_JSON_SERVICES.contains(&service) {
546 Some(AwsProtocol::RestJson)
547 } else {
548 None
549 }
550}
551
552fn infer_service_from_action(action: &str) -> Option<String> {
556 match action {
557 "AssumeRole"
558 | "AssumeRoleWithSAML"
559 | "AssumeRoleWithWebIdentity"
560 | "GetCallerIdentity"
561 | "GetSessionToken"
562 | "GetFederationToken"
563 | "GetAccessKeyInfo"
564 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
565 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
566 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
567 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
568 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
569 "VerifyEmailIdentity"
571 | "VerifyDomainIdentity"
572 | "VerifyDomainDkim"
573 | "ListIdentities"
574 | "GetIdentityVerificationAttributes"
575 | "GetIdentityDkimAttributes"
576 | "DeleteIdentity"
577 | "SetIdentityDkimEnabled"
578 | "SetIdentityNotificationTopic"
579 | "SetIdentityFeedbackForwardingEnabled"
580 | "GetIdentityNotificationAttributes"
581 | "GetIdentityMailFromDomainAttributes"
582 | "SetIdentityMailFromDomain"
583 | "SendEmail"
584 | "SendRawEmail"
585 | "SendTemplatedEmail"
586 | "SendBulkTemplatedEmail"
587 | "CreateTemplate"
588 | "GetTemplate"
589 | "ListTemplates"
590 | "DeleteTemplate"
591 | "UpdateTemplate"
592 | "CreateConfigurationSet"
593 | "DeleteConfigurationSet"
594 | "DescribeConfigurationSet"
595 | "ListConfigurationSets"
596 | "CreateConfigurationSetEventDestination"
597 | "UpdateConfigurationSetEventDestination"
598 | "DeleteConfigurationSetEventDestination"
599 | "GetSendQuota"
600 | "GetSendStatistics"
601 | "GetAccountSendingEnabled"
602 | "CreateReceiptRuleSet"
603 | "DeleteReceiptRuleSet"
604 | "DescribeReceiptRuleSet"
605 | "ListReceiptRuleSets"
606 | "CloneReceiptRuleSet"
607 | "SetActiveReceiptRuleSet"
608 | "ReorderReceiptRuleSet"
609 | "CreateReceiptRule"
610 | "DeleteReceiptRule"
611 | "DescribeReceiptRule"
612 | "UpdateReceiptRule"
613 | "CreateReceiptFilter"
614 | "DeleteReceiptFilter"
615 | "ListReceiptFilters" => Some("ses".to_string()),
616 "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
620 _ => None,
621 }
622}
623
624fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
626 let auth = headers.get("authorization")?.to_str().ok()?;
627 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
628 Some(normalize_service_name(&info.service).to_string())
629}
630
631fn normalize_service_name(service: &str) -> &str {
643 match service {
644 "bedrock-runtime" => "bedrock",
645 "apigatewayv2" => "apigateway",
653 "opensearch" => "es",
661 "appconfigdata" => "appconfig",
667 other => other,
668 }
669}
670
671pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
673 decode_form_urlencoded(body)
674}
675
676pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
693 let mut out = HashMap::new();
694 let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
695 return out;
696 };
697 if value.is_object() {
698 flatten_json_value("", &value, &mut out);
699 }
700 out
701}
702
703fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
704 match value {
705 serde_json::Value::Object(map) => {
706 for (k, v) in map {
707 let child = if prefix.is_empty() {
708 k.clone()
709 } else {
710 format!("{prefix}.{k}")
711 };
712 flatten_json_value(&child, v, out);
713 }
714 }
715 serde_json::Value::Array(items) => {
716 for (i, v) in items.iter().enumerate() {
717 let child = format!("{prefix}.member.{}", i + 1);
718 flatten_json_value(&child, v, out);
719 }
720 }
721 serde_json::Value::Null => {}
722 serde_json::Value::String(s) => {
723 out.insert(prefix.to_string(), s.clone());
724 }
725 serde_json::Value::Bool(b) => {
726 out.insert(prefix.to_string(), b.to_string());
727 }
728 serde_json::Value::Number(n) => {
729 out.insert(prefix.to_string(), n.to_string());
730 }
731 }
732}
733
734fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
735 let s = std::str::from_utf8(input).unwrap_or("");
736 let mut result = HashMap::new();
737 for pair in s.split('&') {
738 if pair.is_empty() {
739 continue;
740 }
741 let (key, value) = match pair.find('=') {
742 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
743 None => (pair, ""),
744 };
745 result.insert(url_decode(key), url_decode(value));
746 }
747 result
748}
749
750fn url_decode(input: &str) -> String {
751 let mut result = String::with_capacity(input.len());
752 let mut bytes = input.bytes();
753 while let Some(b) = bytes.next() {
754 match b {
755 b'+' => result.push(' '),
756 b'%' => {
757 let high = bytes.next().and_then(from_hex);
758 let low = bytes.next().and_then(from_hex);
759 if let (Some(h), Some(l)) = (high, low) {
760 result.push((h << 4 | l) as char);
761 }
762 }
763 _ => result.push(b as char),
764 }
765 }
766 result
767}
768
769fn from_hex(b: u8) -> Option<u8> {
770 match b {
771 b'0'..=b'9' => Some(b - b'0'),
772 b'a'..=b'f' => Some(b - b'a' + 10),
773 b'A'..=b'F' => Some(b - b'A' + 10),
774 _ => None,
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
783 fn parse_amz_target_events() {
784 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
785 assert_eq!(result.service, "events");
786 assert_eq!(result.action, "PutEvents");
787 assert_eq!(result.protocol, AwsProtocol::Json);
788 }
789
790 #[test]
791 fn parse_amz_target_ssm() {
792 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
793 assert_eq!(result.service, "ssm");
794 assert_eq!(result.action, "GetParameter");
795 }
796
797 #[test]
798 fn parse_amz_target_kinesis() {
799 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
800 assert_eq!(result.service, "kinesis");
801 assert_eq!(result.action, "ListStreams");
802 assert_eq!(result.protocol, AwsProtocol::Json);
803 }
804
805 #[test]
806 fn parse_query_body_basic() {
807 let body = Bytes::from(
808 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
809 );
810 let params = parse_query_body(&body);
811 assert_eq!(params.get("Action").unwrap(), "SendMessage");
812 assert_eq!(params.get("MessageBody").unwrap(), "hello");
813 }
814
815 #[test]
816 fn parse_query_body_empty_returns_empty_map() {
817 let body = Bytes::from("");
818 let params = parse_query_body(&body);
819 assert!(params.is_empty());
820 }
821
822 #[test]
823 fn parse_query_body_duplicate_keys_last_wins() {
824 let body = Bytes::from("key=a&key=b");
825 let params = parse_query_body(&body);
826 assert_eq!(params.get("key").unwrap(), "b");
827 }
828
829 #[test]
830 fn parse_query_body_single_key() {
831 let body = Bytes::from("key=value");
832 let params = parse_query_body(&body);
833 assert_eq!(params.get("key").unwrap(), "value");
834 }
835
836 #[test]
837 fn parse_amz_target_ecs() {
838 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
839 assert_eq!(result.service, "ecs");
840 assert_eq!(result.action, "ListClusters");
841 assert_eq!(result.protocol, AwsProtocol::Json);
842 }
843
844 #[test]
845 fn parse_amz_target_invalid_returns_none() {
846 assert!(parse_amz_target("NoDotHere").is_none());
847 assert!(parse_amz_target("").is_none());
848 }
849
850 #[test]
851 fn parse_amz_target_cloudwatch_json() {
852 let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
854 assert_eq!(result.service, "monitoring");
855 assert_eq!(result.action, "PutMetricData");
856 assert_eq!(result.protocol, AwsProtocol::Json);
857 }
858
859 #[test]
860 fn flatten_json_to_query_nested() {
861 let body = Bytes::from(
862 serde_json::json!({
863 "Namespace": "MyApp",
864 "MetricData": [{
865 "MetricName": "Latency",
866 "Value": 12.5,
867 "StatisticValues": {"SampleCount": 3, "Sum": 10},
868 "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
869 }]
870 })
871 .to_string(),
872 );
873 let flat = flatten_json_to_query(&body);
874 assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
875 assert_eq!(
876 flat.get("MetricData.member.1.MetricName").unwrap(),
877 "Latency"
878 );
879 assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
880 assert_eq!(
881 flat.get("MetricData.member.1.StatisticValues.SampleCount")
882 .unwrap(),
883 "3"
884 );
885 assert_eq!(
886 flat.get("MetricData.member.1.Dimensions.member.1.Name")
887 .unwrap(),
888 "Endpoint"
889 );
890 assert_eq!(
891 flat.get("MetricData.member.1.Dimensions.member.1.Value")
892 .unwrap(),
893 "/api"
894 );
895 }
896
897 #[test]
898 fn flatten_json_to_query_non_object_is_empty() {
899 assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
900 assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
901 }
902
903 #[test]
904 fn parse_amz_target_various_prefixes() {
905 assert_eq!(
906 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
907 "sqs"
908 );
909 assert_eq!(
910 parse_amz_target("AmazonSNS.Publish").unwrap().service,
911 "sns"
912 );
913 assert_eq!(
914 parse_amz_target("DynamoDB_20120810.GetItem")
915 .unwrap()
916 .service,
917 "dynamodb"
918 );
919 assert_eq!(
920 parse_amz_target("Logs_20140328.PutLogEvents")
921 .unwrap()
922 .service,
923 "logs"
924 );
925 assert_eq!(
926 parse_amz_target("secretsmanager.GetSecretValue")
927 .unwrap()
928 .service,
929 "secretsmanager"
930 );
931 assert_eq!(
932 parse_amz_target("TrentService.Encrypt").unwrap().service,
933 "kms"
934 );
935 assert_eq!(
936 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
937 .unwrap()
938 .service,
939 "cognito-idp"
940 );
941 assert_eq!(
942 parse_amz_target("AWSStepFunctions.StartExecution")
943 .unwrap()
944 .service,
945 "states"
946 );
947 assert_eq!(
948 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
949 .unwrap()
950 .service,
951 "organizations"
952 );
953 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
954 }
955
956 #[test]
957 fn infer_service_from_action_maps_sts() {
958 assert_eq!(
959 infer_service_from_action("AssumeRole").as_deref(),
960 Some("sts")
961 );
962 assert_eq!(
963 infer_service_from_action("GetCallerIdentity").as_deref(),
964 Some("sts")
965 );
966 }
967
968 #[test]
969 fn infer_service_from_action_maps_iam() {
970 assert_eq!(
971 infer_service_from_action("CreateUser").as_deref(),
972 Some("iam")
973 );
974 assert_eq!(
975 infer_service_from_action("ListRoles").as_deref(),
976 Some("iam")
977 );
978 }
979
980 #[test]
981 fn infer_service_from_action_maps_ses() {
982 assert_eq!(
983 infer_service_from_action("SendEmail").as_deref(),
984 Some("ses")
985 );
986 assert_eq!(
987 infer_service_from_action("ListIdentities").as_deref(),
988 Some("ses")
989 );
990 }
991
992 #[test]
993 fn infer_service_from_action_maps_sns_confirmation_flow() {
994 assert_eq!(
997 infer_service_from_action("ConfirmSubscription").as_deref(),
998 Some("sns")
999 );
1000 assert_eq!(
1001 infer_service_from_action("Unsubscribe").as_deref(),
1002 Some("sns")
1003 );
1004 }
1005
1006 #[test]
1007 fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1008 let mut headers = HeaderMap::new();
1011 headers.insert("host", "localhost:4566".parse().unwrap());
1012 let mut query_params = HashMap::new();
1013 query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1014 query_params.insert(
1015 "TopicArn".to_string(),
1016 "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1017 );
1018 query_params.insert("Token".to_string(), "abc123".to_string());
1019
1020 let detected = detect_service(&headers, &query_params, &Bytes::new())
1021 .expect("ConfirmSubscription must route to a service");
1022 assert_eq!(detected.service, "sns");
1023 assert_eq!(detected.action, "ConfirmSubscription");
1024 assert_eq!(detected.protocol, AwsProtocol::Query);
1025 }
1026
1027 #[test]
1028 fn infer_service_from_action_unknown_returns_none() {
1029 assert!(infer_service_from_action("NotARealAction").is_none());
1030 }
1031
1032 #[test]
1033 fn rest_protocol_for_returns_none_for_non_rest_service() {
1034 assert!(rest_protocol_for("sqs").is_none());
1035 }
1036
1037 #[test]
1038 fn url_decode_handles_percent_and_plus() {
1039 assert_eq!(url_decode("hello+world"), "hello world");
1040 assert_eq!(url_decode("hello%20world"), "hello world");
1041 assert_eq!(url_decode("100%25"), "100%");
1042 }
1043
1044 #[test]
1045 fn url_decode_ignores_malformed_percent() {
1046 assert_eq!(url_decode("%ZZ"), "");
1047 }
1048
1049 #[test]
1050 fn from_hex_valid_digits() {
1051 assert_eq!(from_hex(b'0'), Some(0));
1052 assert_eq!(from_hex(b'9'), Some(9));
1053 assert_eq!(from_hex(b'a'), Some(10));
1054 assert_eq!(from_hex(b'F'), Some(15));
1055 }
1056
1057 #[test]
1058 fn from_hex_invalid_returns_none() {
1059 assert!(from_hex(b'g').is_none());
1060 assert!(from_hex(b' ').is_none());
1061 }
1062
1063 #[test]
1064 fn detect_service_via_amz_target() {
1065 let mut headers = HeaderMap::new();
1066 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1067 let query = HashMap::new();
1068 let body = Bytes::new();
1069 let detected = detect_service(&headers, &query, &body).unwrap();
1070 assert_eq!(detected.service, "ssm");
1071 assert_eq!(detected.action, "GetParameter");
1072 }
1073
1074 #[test]
1075 fn detect_service_via_query_action_with_inferred_service() {
1076 let headers = HeaderMap::new();
1077 let mut query = HashMap::new();
1078 query.insert("Action".to_string(), "AssumeRole".to_string());
1079 let body = Bytes::new();
1080 let detected = detect_service(&headers, &query, &body).unwrap();
1081 assert_eq!(detected.service, "sts");
1082 assert_eq!(detected.action, "AssumeRole");
1083 assert_eq!(detected.protocol, AwsProtocol::Query);
1084 }
1085
1086 #[test]
1087 fn detect_service_via_form_body() {
1088 let headers = HeaderMap::new();
1089 let query = HashMap::new();
1090 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1091 let detected = detect_service(&headers, &query, &body).unwrap();
1092 assert_eq!(detected.service, "ses");
1093 assert_eq!(detected.action, "SendEmail");
1094 }
1095
1096 #[test]
1097 fn detect_service_via_sigv2_presigned() {
1098 let headers = HeaderMap::new();
1099 let mut query = HashMap::new();
1100 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1101 query.insert("Signature".to_string(), "sig".to_string());
1102 query.insert("Expires".to_string(), "1234567890".to_string());
1103 let body = Bytes::new();
1104 let detected = detect_service(&headers, &query, &body).unwrap();
1105 assert_eq!(detected.service, "s3");
1106 assert_eq!(detected.protocol, AwsProtocol::Rest);
1107 }
1108
1109 #[test]
1110 fn detect_service_via_sigv4_presigned_credential() {
1111 let headers = HeaderMap::new();
1112 let mut query = HashMap::new();
1113 query.insert(
1114 "X-Amz-Credential".to_string(),
1115 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1116 );
1117 let body = Bytes::new();
1118 let detected = detect_service(&headers, &query, &body).unwrap();
1119 assert_eq!(detected.service, "s3");
1120 assert_eq!(detected.protocol, AwsProtocol::Rest);
1121 }
1122
1123 #[test]
1124 fn detect_service_unknown_returns_none() {
1125 let headers = HeaderMap::new();
1126 let query = HashMap::new();
1127 let body = Bytes::new();
1128 assert!(detect_service(&headers, &query, &body).is_none());
1129 }
1130
1131 #[test]
1132 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1133 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1138 }
1139
1140 #[test]
1141 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1142 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1147 }
1148
1149 #[test]
1150 fn normalize_service_name_passes_through_unaliased_services() {
1151 assert_eq!(normalize_service_name("bedrock"), "bedrock");
1155 assert_eq!(normalize_service_name("s3"), "s3");
1156 assert_eq!(normalize_service_name("lambda"), "lambda");
1157 assert_eq!(normalize_service_name(""), "");
1158 assert_eq!(
1159 normalize_service_name("unknown-future-service"),
1160 "unknown-future-service"
1161 );
1162 }
1163
1164 #[test]
1165 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1166 let mut headers = HeaderMap::new();
1171 headers.insert(
1172 "authorization",
1173 "AWS4-HMAC-SHA256 \
1174 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1175 SignedHeaders=host, Signature=abc"
1176 .parse()
1177 .unwrap(),
1178 );
1179 let query = HashMap::new();
1180 let body = Bytes::new();
1181 let detected = detect_service(&headers, &query, &body).unwrap();
1182 assert_eq!(detected.service, "bedrock");
1183 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1184 }
1185
1186 #[test]
1187 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1188 let headers = HeaderMap::new();
1192 let mut query = HashMap::new();
1193 query.insert(
1194 "X-Amz-Credential".to_string(),
1195 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1196 );
1197 let body = Bytes::new();
1198 let detected = detect_service(&headers, &query, &body).unwrap();
1199 assert_eq!(detected.service, "bedrock");
1200 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1201 }
1202
1203 #[test]
1204 fn parse_routing_host_localstack_basic() {
1205 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1206 assert_eq!(h.service, "sqs");
1207 assert_eq!(h.region, "us-east-1");
1208 assert!(h.bucket.is_none());
1209 }
1210
1211 #[test]
1212 fn parse_routing_host_localstack_with_port() {
1213 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1214 assert_eq!(h.service, "lambda");
1215 assert_eq!(h.region, "eu-west-1");
1216 assert!(h.bucket.is_none());
1217 }
1218
1219 #[test]
1220 fn parse_routing_host_case_insensitive() {
1221 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1222 assert_eq!(h.service, "sqs");
1223 assert_eq!(h.region, "us-east-1");
1224
1225 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1226 assert_eq!(h.service, "lambda");
1227 assert_eq!(h.region, "us-east-1");
1228 }
1229
1230 #[test]
1231 fn parse_routing_host_localstack_s3_virtual_hosted() {
1232 let h =
1233 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1234 assert_eq!(h.service, "s3");
1235 assert_eq!(h.region, "us-east-1");
1236 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1237 }
1238
1239 #[test]
1240 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1241 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1242 assert_eq!(h.service, "s3");
1243 assert_eq!(h.region, "us-east-1");
1244 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1245 }
1246
1247 #[test]
1248 fn parse_routing_host_aws_service_region() {
1249 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1250 assert_eq!(h.service, "sqs");
1251 assert_eq!(h.region, "us-east-1");
1252 assert!(h.bucket.is_none());
1253
1254 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1255 assert_eq!(h.service, "dynamodb");
1256 assert_eq!(h.region, "eu-west-2");
1257 }
1258
1259 #[test]
1260 fn parse_routing_host_aws_s3_path_style_modern() {
1261 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1262 assert_eq!(h.service, "s3");
1263 assert_eq!(h.region, "us-east-1");
1264 assert!(h.bucket.is_none());
1265 }
1266
1267 #[test]
1268 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1269 let h = parse_routing_host("my-bucket.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("my-bucket"));
1273 }
1274
1275 #[test]
1276 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1277 let h = parse_routing_host("a.b.c.s3.us-east-1.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("a.b.c"));
1281 }
1282
1283 #[test]
1284 fn parse_routing_host_aws_s3_legacy_global() {
1285 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1288 assert_eq!(h.service, "s3");
1289 assert_eq!(h.region, "us-east-1");
1290 assert!(h.bucket.is_none());
1291
1292 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1293 assert_eq!(h.service, "s3");
1294 assert_eq!(h.region, "us-east-1");
1295 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1296 }
1297
1298 #[test]
1299 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1300 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1303 assert_eq!(h.service, "s3");
1304 assert_eq!(h.region, "us-east-1");
1305 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1306 }
1307
1308 #[test]
1309 fn parse_routing_host_aws_s3_dash_separated() {
1310 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1312 assert_eq!(h.service, "s3");
1313 assert_eq!(h.region, "us-west-2");
1314 assert!(h.bucket.is_none());
1315
1316 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1317 assert_eq!(h.service, "s3");
1318 assert_eq!(h.region, "us-west-2");
1319 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1320 }
1321
1322 #[test]
1323 fn parse_routing_host_rejects_plain_localhost() {
1324 assert!(parse_routing_host("localhost:4566").is_none());
1325 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1326 }
1327
1328 #[test]
1329 fn parse_routing_host_rejects_unknown_suffix() {
1330 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1331 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1332 }
1333
1334 #[test]
1335 fn parse_routing_host_empty_and_malformed_rejected() {
1336 assert!(parse_routing_host("").is_none());
1337 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1338 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1339 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1340 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1341 assert!(parse_routing_host(".amazonaws.com").is_none());
1342 assert!(parse_routing_host("amazonaws.com").is_none());
1343 }
1344
1345 #[test]
1346 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1347 assert!(parse_routing_host("s3-accesspoint").is_none());
1351 }
1352
1353 #[test]
1354 fn detect_service_via_host_for_rest_service() {
1355 let mut headers = HeaderMap::new();
1356 headers.insert(
1357 "host",
1358 "s3.us-east-1.localhost.localstack.cloud:4566"
1359 .parse()
1360 .unwrap(),
1361 );
1362 let query = HashMap::new();
1363 let body = Bytes::new();
1364 let detected = detect_service(&headers, &query, &body).unwrap();
1365 assert_eq!(detected.service, "s3");
1366 assert_eq!(detected.protocol, AwsProtocol::Rest);
1367 }
1368
1369 #[test]
1370 fn detect_service_via_host_for_rest_json_service() {
1371 let mut headers = HeaderMap::new();
1372 headers.insert(
1373 "host",
1374 "lambda.us-east-1.localhost.localstack.cloud:4566"
1375 .parse()
1376 .unwrap(),
1377 );
1378 let query = HashMap::new();
1379 let body = Bytes::new();
1380 let detected = detect_service(&headers, &query, &body).unwrap();
1381 assert_eq!(detected.service, "lambda");
1382 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1383 }
1384
1385 #[test]
1386 fn detect_service_via_host_plus_query_action() {
1387 let mut headers = HeaderMap::new();
1388 headers.insert(
1389 "host",
1390 "sqs.us-east-1.localhost.localstack.cloud:4566"
1391 .parse()
1392 .unwrap(),
1393 );
1394 let mut query = HashMap::new();
1395 query.insert("Action".to_string(), "ListQueues".to_string());
1396 let body = Bytes::new();
1397 let detected = detect_service(&headers, &query, &body).unwrap();
1398 assert_eq!(detected.service, "sqs");
1399 assert_eq!(detected.action, "ListQueues");
1400 assert_eq!(detected.protocol, AwsProtocol::Query);
1401 }
1402
1403 #[test]
1404 fn detect_service_sigv4_wins_over_host() {
1405 let mut headers = HeaderMap::new();
1406 headers.insert(
1407 "authorization",
1408 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1409 SignedHeaders=host, Signature=abc"
1410 .parse()
1411 .unwrap(),
1412 );
1413 headers.insert(
1414 "host",
1415 "lambda.us-east-1.localhost.localstack.cloud:4566"
1416 .parse()
1417 .unwrap(),
1418 );
1419 let query = HashMap::new();
1420 let body = Bytes::new();
1421 let detected = detect_service(&headers, &query, &body).unwrap();
1422 assert_eq!(detected.service, "s3");
1424 assert_eq!(detected.protocol, AwsProtocol::Rest);
1425 }
1426
1427 #[test]
1428 fn detect_service_host_for_virtual_hosted_s3() {
1429 let mut headers = HeaderMap::new();
1430 headers.insert(
1431 "host",
1432 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1433 .parse()
1434 .unwrap(),
1435 );
1436 let query = HashMap::new();
1437 let body = Bytes::new();
1438 let detected = detect_service(&headers, &query, &body).unwrap();
1439 assert_eq!(detected.service, "s3");
1440 assert_eq!(detected.protocol, AwsProtocol::Rest);
1441 }
1442}