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