1use bytes::Bytes;
2use http::HeaderMap;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum AwsProtocol {
8 Query,
11 Ec2Query,
21 Json,
24 Rest,
27 RestJson,
30 RpcV2Cbor,
35}
36
37fn query_protocol_for(service: &str) -> AwsProtocol {
41 if service == "ec2" {
42 AwsProtocol::Ec2Query
43 } else {
44 AwsProtocol::Query
45 }
46}
47
48const REST_XML_SERVICES: &[&str] = &["s3", "cloudfront", "route53"];
50
51const REST_JSON_SERVICES: &[&str] = &[
53 "managedblockchain",
54 "lambda",
55 "ses",
56 "apigateway",
57 "bedrock",
58 "bedrock-agent",
59 "bedrock-agent-runtime",
60 "scheduler",
61 "batch",
62 "pipes",
63 "rds-data",
64 "dsql",
65 "resource-groups",
66 "eks",
67 "glacier",
68 "backup",
69 "ram",
72 "s3tables",
75 "lakeformation",
78 "es",
82 "account",
83 "appconfig",
87 "codeartifact",
90 "elasticfilesystem",
94 "mq",
97 "kafka",
101 "mwaa",
105 "fis",
110 "xray",
115 "appsync",
120 "amplify",
125 "mediaconvert",
130 "serverlessrepo",
135 "iotdata",
140 "pinpoint",
146 "iot",
151 "iotwireless",
156];
157
158pub fn query_action_is_a_parameter(
168 headers: &HeaderMap,
169 query_params: &HashMap<String, String>,
170) -> bool {
171 const PARAMETER_SERVICES: &[&str] = &["s3", "execute-api"];
172 let host = parse_routing_host_from_headers(headers)
173 .is_some_and(|h| PARAMETER_SERVICES.contains(&h.service.as_str()) || h.bucket.is_some())
174 || headers
175 .get(http::header::HOST)
176 .and_then(|v| v.to_str().ok())
177 .is_some_and(|h| h.contains(".execute-api."));
178 let scope = extract_service_from_auth(headers)
179 .or_else(|| {
180 let credential = query_params.get("X-Amz-Credential")?;
181 Some(normalize_service_name(credential.split('/').nth(3)?).to_string())
182 })
183 .is_some_and(|s| PARAMETER_SERVICES.contains(&s.as_str()));
184 let sigv2_presign = query_params.contains_key("AWSAccessKeyId")
185 && query_params.contains_key("Signature")
186 && query_params.contains_key("Expires")
187 && !query_params.contains_key("SignatureVersion");
188 host || scope || sigv2_presign
189}
190
191fn query_string_action(
196 headers: &HeaderMap,
197 query_params: &HashMap<String, String>,
198) -> Option<DetectedRequest> {
199 let action = query_params.get("Action")?;
200 if query_action_is_a_parameter(headers, query_params) {
201 return None;
202 }
203 let service = extract_service_from_auth(headers)
204 .or_else(|| infer_service_from_action(action))
205 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service))?;
206 let protocol = query_protocol_for(&service);
207 Some(DetectedRequest {
208 service,
209 action: action.clone(),
210 protocol,
211 })
212}
213
214#[derive(Debug, Clone)]
216pub struct DetectedRequest {
217 pub service: String,
218 pub action: String,
219 pub protocol: AwsProtocol,
220}
221
222pub fn detect_service_headers_only(
229 headers: &HeaderMap,
230 query_params: &HashMap<String, String>,
231) -> Option<DetectedRequest> {
232 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
234 return parse_amz_target(target);
235 }
236 if let Some(detected) = query_string_action(headers, query_params) {
237 return Some(detected);
238 }
239 if let Some(service) = extract_service_from_auth(headers) {
240 if let Some(protocol) = rest_protocol_for(&service) {
241 return Some(DetectedRequest {
242 service,
243 action: String::new(),
244 protocol,
245 });
246 }
247 }
248 if let Some(credential) = query_params.get("X-Amz-Credential") {
249 let parts: Vec<&str> = credential.split('/').collect();
250 if parts.len() >= 4 {
251 let service = normalize_service_name(parts[3]).to_string();
252 if let Some(protocol) = rest_protocol_for(&service) {
253 return Some(DetectedRequest {
254 service,
255 action: String::new(),
256 protocol,
257 });
258 }
259 }
260 }
261 if query_params.contains_key("AWSAccessKeyId")
262 && query_params.contains_key("Signature")
263 && query_params.contains_key("Expires")
264 {
265 return Some(DetectedRequest {
266 service: "s3".to_string(),
267 action: String::new(),
268 protocol: AwsProtocol::Rest,
269 });
270 }
271 if let Some(host_info) = parse_routing_host_from_headers(headers) {
272 if let Some(protocol) = rest_protocol_for(&host_info.service) {
273 return Some(DetectedRequest {
274 service: host_info.service,
275 action: String::new(),
276 protocol,
277 });
278 }
279 }
280 None
281}
282
283pub fn headers_name_final_service(
292 headers: &HeaderMap,
293 query_params: &HashMap<String, String>,
294) -> bool {
295 !is_form_content_type(headers)
296 || headers.contains_key("x-amz-target")
297 || query_params.get("Action").is_some_and(|action| {
298 extract_service_from_auth(headers)
299 .or_else(|| infer_service_from_action(action))
300 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service))
301 .is_some()
302 })
303 || extract_service_from_auth(headers).is_some()
304}
305
306pub fn is_form_content_type(headers: &HeaderMap) -> bool {
311 headers
312 .get(http::header::CONTENT_TYPE)
313 .and_then(|v| v.to_str().ok())
314 .and_then(|ct| ct.split(';').next())
315 .is_some_and(|media| {
316 media
317 .trim()
318 .eq_ignore_ascii_case("application/x-www-form-urlencoded")
319 })
320}
321
322pub fn detect_service(
324 headers: &HeaderMap,
325 query_params: &HashMap<String, String>,
326 body: &Bytes,
327) -> Option<DetectedRequest> {
328 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
330 return parse_amz_target(target);
331 }
332
333 if let Some(detected) = query_string_action(headers, query_params) {
336 return Some(detected);
337 }
338
339 if is_form_content_type(headers) {
343 let form_params = decode_form_urlencoded(body);
344
345 if let Some(action) = form_params.get("Action") {
346 let service = extract_service_from_auth(headers)
347 .or_else(|| infer_service_from_action(action))
348 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
349 if let Some(service) = service {
350 let protocol = query_protocol_for(&service);
351 return Some(DetectedRequest {
352 service,
353 action: action.clone(),
354 protocol,
355 });
356 }
357 }
358 }
359
360 if let Some(service) = extract_service_from_auth(headers) {
362 if let Some(protocol) = rest_protocol_for(&service) {
363 return Some(DetectedRequest {
364 service,
365 action: String::new(), protocol,
367 });
368 }
369 }
370
371 if let Some(credential) = query_params.get("X-Amz-Credential") {
373 let parts: Vec<&str> = credential.split('/').collect();
375 if parts.len() >= 4 {
376 let service = normalize_service_name(parts[3]).to_string();
377 if let Some(protocol) = rest_protocol_for(&service) {
378 return Some(DetectedRequest {
379 service,
380 action: String::new(),
381 protocol,
382 });
383 }
384 }
385 }
386
387 if query_params.contains_key("AWSAccessKeyId")
391 && query_params.contains_key("Signature")
392 && query_params.contains_key("Expires")
393 {
394 return Some(DetectedRequest {
395 service: "s3".to_string(),
396 action: String::new(),
397 protocol: AwsProtocol::Rest,
398 });
399 }
400
401 if let Some(host_info) = parse_routing_host_from_headers(headers) {
405 if let Some(protocol) = rest_protocol_for(&host_info.service) {
406 return Some(DetectedRequest {
407 service: host_info.service,
408 action: String::new(),
409 protocol,
410 });
411 }
412 }
413
414 None
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
427pub struct RoutingHost {
428 pub service: String,
429 pub region: String,
430 pub bucket: Option<String>,
432}
433
434const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
435
436fn host_label_service(label: &str) -> String {
439 match label {
440 "email" => "ses".to_string(),
441 other => other.to_string(),
442 }
443}
444
445const GLOBAL_ENDPOINT_SERVICES: &[&str] = &["iam", "sts", "route53", "cloudfront"];
449
450pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
454 let hostname = host.split(':').next()?;
455 if hostname.is_empty() {
456 return None;
457 }
458 let hostname = hostname.to_ascii_lowercase();
459 if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
460 return parse_localstack_prefix(prefix);
461 }
462 if let Some(service) = hostname
465 .strip_suffix(".amazonaws.com")
466 .filter(|s| GLOBAL_ENDPOINT_SERVICES.contains(s))
467 {
468 return Some(RoutingHost {
469 service: service.to_string(),
470 region: "us-east-1".to_string(),
471 bucket: None,
472 });
473 }
474 fakecloud_aws::endpoint::DNS_SUFFIXES
477 .iter()
478 .find_map(|suffix| hostname.strip_suffix(suffix)?.strip_suffix('.'))
479 .and_then(parse_aws_prefix)
480}
481
482pub fn is_s3_control_host(headers: &HeaderMap) -> bool {
488 let Some(host) = headers.get("host").and_then(|v| v.to_str().ok()) else {
489 return false;
490 };
491 let hostname = host.split(':').next().unwrap_or("").to_ascii_lowercase();
492 let labels: Vec<&str> = hostname.split('.').collect();
493 let control_label = match labels.as_slice() {
494 ["s3-control", ..] => true,
495 [account, "s3-control", ..] => {
496 account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit())
497 }
498 _ => false,
499 };
500 control_label && parse_routing_host(host).is_none_or(|h| h.bucket.is_none())
501}
502
503pub fn normalize_host(host: &str) -> String {
508 let host = host.trim();
509 let without_port = if let Some(rest) = host.strip_prefix('[') {
510 match rest.find(']') {
511 Some(end) => &host[..end + 2],
512 None => host,
513 }
514 } else {
515 match host.rsplit_once(':') {
516 Some((name, port)) if port.chars().all(|c| c.is_ascii_digit()) => name,
517 _ => host,
518 }
519 };
520 without_port.trim_end_matches('.').to_ascii_lowercase()
521}
522
523pub fn normalized_host_from_headers(headers: &HeaderMap) -> Option<String> {
525 let host = headers.get("host")?.to_str().ok()?;
526 let host = normalize_host(host);
527 (!host.is_empty()).then_some(host)
528}
529
530pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
532 let host = headers.get("host")?.to_str().ok()?;
533 parse_routing_host(host)
534}
535
536fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
537 if prefix.is_empty() {
538 return None;
539 }
540 let labels: Vec<&str> = prefix.split('.').collect();
541 if labels.iter().any(|l| l.is_empty()) {
542 return None;
543 }
544 if let Some(host) = parse_execute_api_labels(&labels) {
549 return host;
550 }
551 match labels.len() {
552 2 => Some(RoutingHost {
553 service: host_label_service(labels[0]),
554 region: labels[1].to_string(),
555 bucket: None,
556 }),
557 n if n >= 3 && labels[n - 2] == "s3" => {
558 let bucket = labels[..n - 2].join(".");
559 Some(RoutingHost {
560 service: "s3".to_string(),
561 region: labels[n - 1].to_string(),
562 bucket: Some(bucket),
563 })
564 }
565 n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
566 let bucket = labels[..n - 2].join(".");
567 Some(RoutingHost {
568 service: "s3".to_string(),
569 region: labels[n - 1].to_string(),
570 bucket: Some(bucket),
571 })
572 }
573 n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
574 service: "s3".to_string(),
575 region: labels[n - 1].to_string(),
576 bucket: None,
577 }),
578 _ => None,
579 }
580}
581
582fn parse_execute_api_labels(labels: &[&str]) -> Option<Option<RoutingHost>> {
587 match labels {
588 [_api_id, "execute-api"] => Some(None),
589 [_api_id, "execute-api", region] => Some(Some(RoutingHost {
590 service: "execute-api".to_string(),
591 region: region.to_string(),
592 bucket: None,
593 })),
594 _ => None,
595 }
596}
597
598fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
614 if prefix.is_empty() {
615 return None;
616 }
617 let labels: Vec<&str> = prefix.split('.').collect();
618 if labels.iter().any(|l| l.is_empty()) {
619 return None;
620 }
621 let last = *labels.last()?;
622
623 if let Some(region) = last.strip_prefix("s3-website-") {
627 if !region.is_empty() && labels.len() >= 2 {
628 return Some(RoutingHost {
629 service: "s3".to_string(),
630 region: region.to_string(),
631 bucket: Some(labels[..labels.len() - 1].join(".")),
632 });
633 }
634 }
635
636 if let Some(region) = last.strip_prefix("s3-") {
639 if !region.is_empty() {
640 let bucket = if labels.len() >= 2 {
641 Some(labels[..labels.len() - 1].join("."))
642 } else {
643 None
644 };
645 return Some(RoutingHost {
646 service: "s3".to_string(),
647 region: region.to_string(),
648 bucket,
649 });
650 }
651 }
652
653 if last == "s3" {
657 if labels.len() == 1 {
658 return Some(RoutingHost {
659 service: "s3".to_string(),
660 region: "us-east-1".to_string(),
661 bucket: None,
662 });
663 }
664 return Some(RoutingHost {
665 service: "s3".to_string(),
666 region: "us-east-1".to_string(),
667 bucket: Some(labels[..labels.len() - 1].join(".")),
668 });
669 }
670
671 if last == "s3-accesspoint" {
674 if labels.len() == 2 {
675 return Some(RoutingHost {
676 service: "s3".to_string(),
677 region: labels[0].to_string(),
678 bucket: None,
679 });
680 }
681 if labels.len() >= 3 {
685 let bucket = labels[..labels.len() - 2].join(".");
686 return Some(RoutingHost {
687 service: "s3".to_string(),
688 region: labels[labels.len() - 1].to_string(),
689 bucket: Some(bucket),
690 });
691 }
692 }
693
694 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
697 return Some(RoutingHost {
698 service: "s3".to_string(),
699 region: last.to_string(),
700 bucket: None,
701 });
702 }
703
704 let before_region = &labels[..labels.len() - 1];
709 let (endpoint_labels, s3_endpoint) = match before_region {
710 [rest @ .., s3, "dualstack"] if matches!(*s3, "s3" | "s3-fips") => (rest, true),
711 [rest @ .., endpoint] if matches!(*endpoint, "s3" | "s3-fips" | "s3-website") => {
712 (rest, true)
713 }
714 _ => (before_region, false),
715 };
716 if s3_endpoint {
717 return Some(RoutingHost {
718 service: "s3".to_string(),
719 region: last.to_string(),
720 bucket: (!endpoint_labels.is_empty()).then(|| endpoint_labels.join(".")),
721 });
722 }
723
724 if let Some(host) = parse_execute_api_labels(&labels) {
726 return host;
727 }
728
729 match labels.as_slice() {
731 [service, region] => Some(RoutingHost {
732 service: host_label_service(service),
733 region: region.to_string(),
734 bucket: None,
735 }),
736 _ => None,
737 }
738}
739
740pub fn detect_rpc_v2_cbor(headers: &HeaderMap, path: &str) -> Option<DetectedRequest> {
745 let is_cbor = headers
746 .get(crate::cbor::SMITHY_PROTOCOL_HEADER)
747 .and_then(|v| v.to_str().ok())
748 .is_some_and(|v| v.trim().eq_ignore_ascii_case(crate::cbor::RPC_V2_CBOR));
749 if !is_cbor {
750 return None;
751 }
752 let (service_shape, operation) = crate::cbor::parse_rpc_v2_path(path)?;
753 let detected = parse_amz_target(&format!("{service_shape}.{operation}"))?;
754 Some(DetectedRequest {
755 protocol: AwsProtocol::RpcV2Cbor,
756 ..detected
757 })
758}
759
760pub(crate) fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
763 let (prefix, action) = target.rsplit_once('.')?;
764
765 let service = match prefix {
766 "AWSEvents" => "events",
767 "AmazonSSM" => "ssm",
768 "AmazonSQS" => "sqs",
769 "AmazonSNS" => "sns",
770 "DynamoDB_20120810" => "dynamodb",
771 "DynamoDBStreams_20120810" => "dynamodbstreams",
772 "Logs_20140328" => "logs",
773 s if s.starts_with("secretsmanager") => "secretsmanager",
774 s if s.starts_with("TrentService") => "kms",
775 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
776 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
777 s if s.starts_with("Kinesis_20131202") => "kinesis",
778 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
779 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
780 s if s.starts_with("AWSStepFunctions") => "states",
781 s if s.starts_with("AWSOrganizationsV") => "organizations",
782 "CertificateManager" => "acm",
783 "ACMPrivateCA" => "acm-pca",
784 "Route53Resolver" => "route53resolver",
788 "StarlingDoveService" => "config",
791 "AnyScaleFrontendService" => "application-autoscaling",
792 "AWSWAF_20190729" => "wafv2",
795 "AmazonAthena" => "athena",
796 s if s.starts_with("Firehose_") => "firehose",
797 "AWSGlue" => "glue",
798 "ElasticMapReduce" => "emr",
801 "Textract" => "textract",
804 "Transcribe" => "transcribe",
807 "AWSShineFrontendService_20170701" => "translate",
810 "AWSShield_20160616" => "shield",
813 "Comprehend_20171127" => "comprehend",
816 "SimpleWorkflowService" => "swf",
819 "Timestream_20181101" => "timestream",
823 "AWSSupport_20130415" => "support",
826 "CloudApiService" => "cloudcontrolapi",
827 "ResourceGroupsTaggingAPI_20170126" => "tagging",
828 "AmazonMemoryDB" => "memorydb",
829 s if s.starts_with("KinesisAnalytics_20180523") => "kinesisanalyticsv2",
834 "Route53AutoNaming_v20170314" => "servicediscovery",
837 "AmazonDMSv20160101" => "dms",
839 "CloudTrail_20131101" => "cloudtrail",
843 "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
844 "AWSInsightsIndexService" => "ce",
848 "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
849 "TransferService" => "transfer",
851 "CodeBuild_20161006" => "codebuild",
853 "CodeCommit_20150413" => "codecommit",
855 "ServiceQuotasV20190624" => "servicequotas",
857 "AWSIdentityStore" => "identitystore",
859 "SWBExternalService" => "sso",
861 "VerifiedPermissions" => "verifiedpermissions",
863 "CodeConnections_20231201" => "codeconnections",
865 "CodeStar_connections_20191201" => "codeconnections",
871 "CodeDeploy_20141006" => "codedeploy",
873 "CodePipeline_20150709" => "codepipeline",
875 s if s.starts_with("GraniteServiceVersion") => "monitoring",
881 "SageMaker" => "sagemaker",
884 _ => return None,
885 };
886
887 Some(DetectedRequest {
888 service: service.to_string(),
889 action: action.to_string(),
890 protocol: AwsProtocol::Json,
891 })
892}
893
894pub fn is_rest_api_service(service: &str) -> bool {
899 rest_protocol_for(service).is_some()
900}
901
902fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
903 if REST_XML_SERVICES.contains(&service) {
904 Some(AwsProtocol::Rest)
905 } else if REST_JSON_SERVICES.contains(&service) {
906 Some(AwsProtocol::RestJson)
907 } else {
908 None
909 }
910}
911
912fn infer_service_from_action(action: &str) -> Option<String> {
916 match action {
917 "AssumeRole"
918 | "AssumeRoleWithSAML"
919 | "AssumeRoleWithWebIdentity"
920 | "GetCallerIdentity"
921 | "GetSessionToken"
922 | "GetFederationToken"
923 | "GetAccessKeyInfo"
924 | "DecodeAuthorizationMessage"
925 | "AssumeRoot"
926 | "GetWebIdentityToken"
927 | "GetDelegatedAccessToken" => Some("sts".to_string()),
928 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
929 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
930 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
931 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
932 "VerifyEmailIdentity"
934 | "VerifyDomainIdentity"
935 | "VerifyDomainDkim"
936 | "ListIdentities"
937 | "GetIdentityVerificationAttributes"
938 | "GetIdentityDkimAttributes"
939 | "DeleteIdentity"
940 | "SetIdentityDkimEnabled"
941 | "SetIdentityNotificationTopic"
942 | "SetIdentityFeedbackForwardingEnabled"
943 | "GetIdentityNotificationAttributes"
944 | "GetIdentityMailFromDomainAttributes"
945 | "SetIdentityMailFromDomain"
946 | "SendEmail"
947 | "SendRawEmail"
948 | "SendTemplatedEmail"
949 | "SendBulkTemplatedEmail"
950 | "CreateTemplate"
951 | "GetTemplate"
952 | "ListTemplates"
953 | "DeleteTemplate"
954 | "UpdateTemplate"
955 | "CreateConfigurationSet"
956 | "DeleteConfigurationSet"
957 | "DescribeConfigurationSet"
958 | "ListConfigurationSets"
959 | "CreateConfigurationSetEventDestination"
960 | "UpdateConfigurationSetEventDestination"
961 | "DeleteConfigurationSetEventDestination"
962 | "GetSendQuota"
963 | "GetSendStatistics"
964 | "GetAccountSendingEnabled"
965 | "CreateReceiptRuleSet"
966 | "DeleteReceiptRuleSet"
967 | "DescribeReceiptRuleSet"
968 | "ListReceiptRuleSets"
969 | "CloneReceiptRuleSet"
970 | "SetActiveReceiptRuleSet"
971 | "ReorderReceiptRuleSet"
972 | "CreateReceiptRule"
973 | "DeleteReceiptRule"
974 | "DescribeReceiptRule"
975 | "UpdateReceiptRule"
976 | "CreateReceiptFilter"
977 | "DeleteReceiptFilter"
978 | "ListReceiptFilters" => Some("ses".to_string()),
979 "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
983 _ => query_actions::QUERY_ACTION_SERVICES
986 .binary_search_by(|(a, _)| (*a).cmp(action))
987 .ok()
988 .map(|i| query_actions::QUERY_ACTION_SERVICES[i].1.to_string()),
989 }
990}
991
992#[path = "query_actions_gen.rs"]
993mod query_actions;
994
995pub(crate) fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
997 let auth = headers.get("authorization")?.to_str().ok()?;
998 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
999 Some(normalize_service_name(&info.service).to_string())
1000}
1001
1002fn normalize_service_name(service: &str) -> &str {
1014 match service {
1015 "bedrock-runtime" => "bedrock",
1016 "apigatewayv2" => "apigateway",
1024 "opensearch" => "es",
1032 "appconfigdata" => "appconfig",
1038 "airflow" => "mwaa",
1043 "mobiletargeting" => "pinpoint",
1048 other => other,
1049 }
1050}
1051
1052pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
1054 decode_form_urlencoded(body)
1055}
1056
1057pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
1074 let mut out = HashMap::new();
1075 let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
1076 return out;
1077 };
1078 if value.is_object() {
1079 flatten_json_value("", &value, &mut out);
1080 }
1081 out
1082}
1083
1084fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
1085 match value {
1086 serde_json::Value::Object(map) => {
1087 for (k, v) in map {
1088 let child = if prefix.is_empty() {
1089 k.clone()
1090 } else {
1091 format!("{prefix}.{k}")
1092 };
1093 flatten_json_value(&child, v, out);
1094 }
1095 }
1096 serde_json::Value::Array(items) => {
1097 for (i, v) in items.iter().enumerate() {
1098 let child = format!("{prefix}.member.{}", i + 1);
1099 flatten_json_value(&child, v, out);
1100 }
1101 }
1102 serde_json::Value::Null => {}
1103 serde_json::Value::String(s) => {
1104 out.insert(prefix.to_string(), s.clone());
1105 }
1106 serde_json::Value::Bool(b) => {
1107 out.insert(prefix.to_string(), b.to_string());
1108 }
1109 serde_json::Value::Number(n) => {
1110 out.insert(prefix.to_string(), n.to_string());
1111 }
1112 }
1113}
1114
1115pub(crate) fn form_urlencoded_pairs(input: &str) -> Vec<(String, String)> {
1121 let mut pairs = Vec::new();
1122 for pair in input.split('&') {
1123 if pair.is_empty() {
1124 continue;
1125 }
1126 let (key, value) = match pair.find('=') {
1127 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1128 None => (pair, ""),
1129 };
1130 pairs.push((url_decode(key), url_decode(value)));
1131 }
1132 pairs
1133}
1134
1135fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
1136 let s = std::str::from_utf8(input).unwrap_or("");
1137 let mut result = HashMap::new();
1138 for pair in s.split('&') {
1139 if pair.is_empty() {
1140 continue;
1141 }
1142 let (key, value) = match pair.find('=') {
1143 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1144 None => (pair, ""),
1145 };
1146 result.insert(url_decode(key), url_decode(value));
1147 }
1148 result
1149}
1150
1151pub fn url_decode(input: &str) -> String {
1156 let mut buf: Vec<u8> = Vec::with_capacity(input.len());
1162 let mut bytes = input.bytes();
1163 while let Some(b) = bytes.next() {
1164 match b {
1165 b'+' => buf.push(b' '),
1166 b'%' => {
1167 let high = bytes.next().and_then(from_hex);
1168 let low = bytes.next().and_then(from_hex);
1169 if let (Some(h), Some(l)) = (high, low) {
1173 buf.push((h << 4) | l);
1174 }
1175 }
1176 _ => buf.push(b),
1177 }
1178 }
1179 String::from_utf8_lossy(&buf).into_owned()
1180}
1181
1182fn from_hex(b: u8) -> Option<u8> {
1183 match b {
1184 b'0'..=b'9' => Some(b - b'0'),
1185 b'a'..=b'f' => Some(b - b'a' + 10),
1186 b'A'..=b'F' => Some(b - b'A' + 10),
1187 _ => None,
1188 }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193 use super::*;
1194
1195 #[test]
1196 fn headers_settle_the_service_only_when_no_form_body_can_change_it() {
1197 let mut plain_host = HeaderMap::new();
1200 plain_host.insert("host", "b.s3.us-east-1.amazonaws.com".parse().unwrap());
1201 let mut host_only = plain_host.clone();
1202 host_only.insert(
1203 "content-type",
1204 "application/x-www-form-urlencoded; charset=utf-8"
1205 .parse()
1206 .unwrap(),
1207 );
1208 let none = HashMap::new();
1209 let body = Bytes::from("Action=GetCallerIdentity&Version=2011-06-15");
1210 assert_eq!(
1211 detect_service_headers_only(&host_only, &none).map(|d| d.service),
1212 Some("s3".to_string())
1213 );
1214 assert_eq!(
1215 detect_service(&host_only, &none, &body).map(|d| d.service),
1216 Some("sts".to_string())
1217 );
1218 assert!(!headers_name_final_service(&host_only, &none));
1219 assert_eq!(
1222 detect_service(&plain_host, &none, &body).map(|d| d.service),
1223 Some("s3".to_string())
1224 );
1225 assert!(headers_name_final_service(&plain_host, &none));
1226 let mut text = plain_host.clone();
1227 text.insert("content-type", "text/plain".parse().unwrap());
1228 assert!(headers_name_final_service(&text, &none));
1229 let presigned: HashMap<String, String> = [(
1231 "X-Amz-Credential".to_string(),
1232 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1233 )]
1234 .into();
1235 let mut form = HeaderMap::new();
1236 form.insert(
1237 "content-type",
1238 "application/x-www-form-urlencoded".parse().unwrap(),
1239 );
1240 assert!(!headers_name_final_service(&form, &presigned));
1241
1242 let mut signed = host_only.clone();
1244 signed.insert(
1245 "authorization",
1246 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1247 SignedHeaders=host, Signature=00"
1248 .parse()
1249 .unwrap(),
1250 );
1251 assert!(headers_name_final_service(&signed, &none));
1252 assert_eq!(
1253 detect_service(&signed, &none, &body).map(|d| d.service),
1254 Some("s3".to_string())
1255 );
1256 let mut target = HeaderMap::new();
1257 target.insert(
1258 "x-amz-target",
1259 "DynamoDB_20120810.ListTables".parse().unwrap(),
1260 );
1261 assert!(headers_name_final_service(&target, &none));
1262 let action: HashMap<String, String> =
1263 [("Action".to_string(), "GetCallerIdentity".to_string())].into();
1264 assert!(headers_name_final_service(&HeaderMap::new(), &action));
1265 }
1266
1267 #[test]
1268 fn s3_control_host_requires_leading_control_label() {
1269 let is_control = |host: &str| {
1270 let mut headers = HeaderMap::new();
1271 headers.insert("host", host.parse().unwrap());
1272 is_s3_control_host(&headers)
1273 };
1274 assert!(is_control("s3-control.us-east-1.amazonaws.com"));
1275 assert!(is_control(
1276 "123456789012.s3-control.us-east-1.amazonaws.com"
1277 ));
1278 assert!(is_control(
1279 "000000000000.s3-control.us-east-1.localhost.localstack.cloud:4566"
1280 ));
1281 assert!(is_control("123456789012.s3-control.localhost"));
1282 assert!(!is_control("my-s3-control-logs.s3.us-east-1.amazonaws.com"));
1283 assert!(!is_control(
1284 "my-s3-control-logs.s3.us-east-1.localhost.localstack.cloud:4566"
1285 ));
1286 assert!(!is_control("a.s3-control.b.s3.us-east-1.amazonaws.com"));
1287 assert!(!is_control("localhost:4566"));
1288 }
1289
1290 #[test]
1291 fn form_urlencoded_pairs_preserves_repeated_keys() {
1292 let pairs = form_urlencoded_pairs("Id=a&Id=b&Id=c&Other=x");
1295 let ids: Vec<&str> = pairs
1296 .iter()
1297 .filter(|(k, _)| k == "Id")
1298 .map(|(_, v)| v.as_str())
1299 .collect();
1300 assert_eq!(ids, vec!["a", "b", "c"]);
1301 assert_eq!(
1303 decode_form_urlencoded(b"Id=a&Id=b&Id=c").get("Id").unwrap(),
1304 "c"
1305 );
1306 }
1307
1308 #[test]
1309 fn form_urlencoded_pairs_decodes_percent_and_plus() {
1310 let pairs = form_urlencoded_pairs("q=caf%C3%A9+bar&empty=");
1311 assert_eq!(pairs[0], ("q".to_string(), "café bar".to_string()));
1312 assert_eq!(pairs[1], ("empty".to_string(), String::new()));
1313 }
1314
1315 #[test]
1316 fn parse_amz_target_events() {
1317 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
1318 assert_eq!(result.service, "events");
1319 assert_eq!(result.action, "PutEvents");
1320 assert_eq!(result.protocol, AwsProtocol::Json);
1321 }
1322
1323 #[test]
1324 fn parse_amz_target_ssm() {
1325 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
1326 assert_eq!(result.service, "ssm");
1327 assert_eq!(result.action, "GetParameter");
1328 }
1329
1330 #[test]
1331 fn parse_amz_target_kinesis() {
1332 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
1333 assert_eq!(result.service, "kinesis");
1334 assert_eq!(result.action, "ListStreams");
1335 assert_eq!(result.protocol, AwsProtocol::Json);
1336 }
1337
1338 #[test]
1339 fn parse_query_body_basic() {
1340 let body = Bytes::from(
1341 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
1342 );
1343 let params = parse_query_body(&body);
1344 assert_eq!(params.get("Action").unwrap(), "SendMessage");
1345 assert_eq!(params.get("MessageBody").unwrap(), "hello");
1346 }
1347
1348 #[test]
1349 fn parse_query_body_empty_returns_empty_map() {
1350 let body = Bytes::from("");
1351 let params = parse_query_body(&body);
1352 assert!(params.is_empty());
1353 }
1354
1355 #[test]
1356 fn parse_query_body_duplicate_keys_last_wins() {
1357 let body = Bytes::from("key=a&key=b");
1358 let params = parse_query_body(&body);
1359 assert_eq!(params.get("key").unwrap(), "b");
1360 }
1361
1362 #[test]
1363 fn parse_query_body_single_key() {
1364 let body = Bytes::from("key=value");
1365 let params = parse_query_body(&body);
1366 assert_eq!(params.get("key").unwrap(), "value");
1367 }
1368
1369 #[test]
1370 fn url_decode_plain_ascii() {
1371 assert_eq!(url_decode("hello"), "hello");
1372 assert_eq!(url_decode("Action=SendMessage"), "Action=SendMessage");
1373 }
1374
1375 #[test]
1376 fn url_decode_plus_is_space() {
1377 assert_eq!(url_decode("hello+world"), "hello world");
1378 assert_eq!(url_decode("a+b+c"), "a b c");
1379 }
1380
1381 #[test]
1382 fn url_decode_multibyte_utf8_accents() {
1383 assert_eq!(url_decode("caf%C3%A9"), "café");
1385 }
1386
1387 #[test]
1388 fn url_decode_multibyte_utf8_cjk() {
1389 assert_eq!(url_decode("%E6%97%A5%E6%9C%AC"), "日本");
1391 }
1392
1393 #[test]
1394 fn url_decode_multibyte_utf8_emoji() {
1395 assert_eq!(url_decode("%F0%9F%9A%80"), "🚀");
1397 }
1398
1399 #[test]
1400 fn url_decode_mixed_ascii_and_multibyte() {
1401 assert_eq!(url_decode("Tag+%3D+caf%C3%A9%21"), "Tag = café!");
1402 }
1403
1404 #[test]
1405 fn url_decode_malformed_percent_is_graceful() {
1406 assert_eq!(url_decode("100%"), "100");
1408 assert_eq!(url_decode("a%zz"), "a");
1409 assert_eq!(url_decode("a%4"), "a");
1410 assert_eq!(url_decode("x%y"), "x");
1412 }
1413
1414 #[test]
1415 fn url_decode_invalid_utf8_bytes_are_lossy_no_panic() {
1416 let out = url_decode("bad%FFbyte");
1418 assert!(out.starts_with("bad"));
1419 assert!(out.ends_with("byte"));
1420 }
1421
1422 #[test]
1423 fn parse_query_body_multibyte_value_round_trips() {
1424 let body = Bytes::from("Tag.Value=caf%C3%A9&Name=%E6%97%A5%E6%9C%AC");
1425 let params = parse_query_body(&body);
1426 assert_eq!(params.get("Tag.Value").unwrap(), "café");
1427 assert_eq!(params.get("Name").unwrap(), "日本");
1428 }
1429
1430 #[test]
1431 fn parse_amz_target_ecs() {
1432 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
1433 assert_eq!(result.service, "ecs");
1434 assert_eq!(result.action, "ListClusters");
1435 assert_eq!(result.protocol, AwsProtocol::Json);
1436 }
1437
1438 #[test]
1439 fn parse_amz_target_invalid_returns_none() {
1440 assert!(parse_amz_target("NoDotHere").is_none());
1441 assert!(parse_amz_target("").is_none());
1442 }
1443
1444 #[test]
1445 fn parse_amz_target_cloudwatch_json() {
1446 let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
1448 assert_eq!(result.service, "monitoring");
1449 assert_eq!(result.action, "PutMetricData");
1450 assert_eq!(result.protocol, AwsProtocol::Json);
1451 }
1452
1453 #[test]
1454 fn flatten_json_to_query_nested() {
1455 let body = Bytes::from(
1456 serde_json::json!({
1457 "Namespace": "MyApp",
1458 "MetricData": [{
1459 "MetricName": "Latency",
1460 "Value": 12.5,
1461 "StatisticValues": {"SampleCount": 3, "Sum": 10},
1462 "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
1463 }]
1464 })
1465 .to_string(),
1466 );
1467 let flat = flatten_json_to_query(&body);
1468 assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
1469 assert_eq!(
1470 flat.get("MetricData.member.1.MetricName").unwrap(),
1471 "Latency"
1472 );
1473 assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
1474 assert_eq!(
1475 flat.get("MetricData.member.1.StatisticValues.SampleCount")
1476 .unwrap(),
1477 "3"
1478 );
1479 assert_eq!(
1480 flat.get("MetricData.member.1.Dimensions.member.1.Name")
1481 .unwrap(),
1482 "Endpoint"
1483 );
1484 assert_eq!(
1485 flat.get("MetricData.member.1.Dimensions.member.1.Value")
1486 .unwrap(),
1487 "/api"
1488 );
1489 }
1490
1491 #[test]
1492 fn flatten_json_to_query_non_object_is_empty() {
1493 assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
1494 assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
1495 }
1496
1497 #[test]
1498 fn parse_amz_target_various_prefixes() {
1499 assert_eq!(
1500 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
1501 "sqs"
1502 );
1503 assert_eq!(
1504 parse_amz_target("AmazonSNS.Publish").unwrap().service,
1505 "sns"
1506 );
1507 assert_eq!(
1508 parse_amz_target("DynamoDB_20120810.GetItem")
1509 .unwrap()
1510 .service,
1511 "dynamodb"
1512 );
1513 assert_eq!(
1514 parse_amz_target("Logs_20140328.PutLogEvents")
1515 .unwrap()
1516 .service,
1517 "logs"
1518 );
1519 assert_eq!(
1520 parse_amz_target("secretsmanager.GetSecretValue")
1521 .unwrap()
1522 .service,
1523 "secretsmanager"
1524 );
1525 assert_eq!(
1526 parse_amz_target("TrentService.Encrypt").unwrap().service,
1527 "kms"
1528 );
1529 assert_eq!(
1530 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
1531 .unwrap()
1532 .service,
1533 "cognito-idp"
1534 );
1535 assert_eq!(
1536 parse_amz_target("AWSStepFunctions.StartExecution")
1537 .unwrap()
1538 .service,
1539 "states"
1540 );
1541 assert_eq!(
1542 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
1543 .unwrap()
1544 .service,
1545 "organizations"
1546 );
1547 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
1548 }
1549
1550 #[test]
1551 fn infer_service_from_action_maps_sts() {
1552 assert_eq!(
1553 infer_service_from_action("AssumeRole").as_deref(),
1554 Some("sts")
1555 );
1556 assert_eq!(
1557 infer_service_from_action("GetCallerIdentity").as_deref(),
1558 Some("sts")
1559 );
1560 for (action, service) in [
1562 ("ListTopics", "sns"),
1563 ("ListQueues", "sqs"),
1564 ("DescribeRegions", "ec2"),
1565 ("DescribeStacks", "cloudformation"),
1566 ("DescribeLoadBalancers", "elasticloadbalancing"),
1567 ] {
1568 assert_eq!(
1569 infer_service_from_action(action).as_deref(),
1570 Some(service),
1571 "{action}"
1572 );
1573 }
1574 assert_eq!(infer_service_from_action("ListTagsForResource"), None);
1576 for action in [
1578 "AssumeRoot",
1579 "GetWebIdentityToken",
1580 "GetDelegatedAccessToken",
1581 ] {
1582 assert_eq!(infer_service_from_action(action).as_deref(), Some("sts"));
1583 }
1584 }
1585
1586 #[test]
1587 fn infer_service_from_action_maps_iam() {
1588 assert_eq!(
1589 infer_service_from_action("CreateUser").as_deref(),
1590 Some("iam")
1591 );
1592 assert_eq!(
1593 infer_service_from_action("ListRoles").as_deref(),
1594 Some("iam")
1595 );
1596 }
1597
1598 #[test]
1599 fn infer_service_from_action_maps_ses() {
1600 assert_eq!(
1601 infer_service_from_action("SendEmail").as_deref(),
1602 Some("ses")
1603 );
1604 assert_eq!(
1605 infer_service_from_action("ListIdentities").as_deref(),
1606 Some("ses")
1607 );
1608 }
1609
1610 #[test]
1611 fn infer_service_from_action_maps_sns_confirmation_flow() {
1612 assert_eq!(
1615 infer_service_from_action("ConfirmSubscription").as_deref(),
1616 Some("sns")
1617 );
1618 assert_eq!(
1619 infer_service_from_action("Unsubscribe").as_deref(),
1620 Some("sns")
1621 );
1622 }
1623
1624 #[test]
1625 fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1626 let mut headers = HeaderMap::new();
1629 headers.insert("host", "localhost:4566".parse().unwrap());
1630 let mut query_params = HashMap::new();
1631 query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1632 query_params.insert(
1633 "TopicArn".to_string(),
1634 "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1635 );
1636 query_params.insert("Token".to_string(), "abc123".to_string());
1637
1638 let detected = detect_service(&headers, &query_params, &Bytes::new())
1639 .expect("ConfirmSubscription must route to a service");
1640 assert_eq!(detected.service, "sns");
1641 assert_eq!(detected.action, "ConfirmSubscription");
1642 assert_eq!(detected.protocol, AwsProtocol::Query);
1643 }
1644
1645 #[test]
1646 fn infer_service_from_action_unknown_returns_none() {
1647 assert!(infer_service_from_action("NotARealAction").is_none());
1648 }
1649
1650 #[test]
1651 fn rest_protocol_for_returns_none_for_non_rest_service() {
1652 assert!(rest_protocol_for("sqs").is_none());
1653 }
1654
1655 #[test]
1656 fn url_decode_handles_percent_and_plus() {
1657 assert_eq!(url_decode("hello+world"), "hello world");
1658 assert_eq!(url_decode("hello%20world"), "hello world");
1659 assert_eq!(url_decode("100%25"), "100%");
1660 }
1661
1662 #[test]
1663 fn url_decode_ignores_malformed_percent() {
1664 assert_eq!(url_decode("%ZZ"), "");
1665 }
1666
1667 #[test]
1668 fn from_hex_valid_digits() {
1669 assert_eq!(from_hex(b'0'), Some(0));
1670 assert_eq!(from_hex(b'9'), Some(9));
1671 assert_eq!(from_hex(b'a'), Some(10));
1672 assert_eq!(from_hex(b'F'), Some(15));
1673 }
1674
1675 #[test]
1676 fn from_hex_invalid_returns_none() {
1677 assert!(from_hex(b'g').is_none());
1678 assert!(from_hex(b' ').is_none());
1679 }
1680
1681 #[test]
1682 fn detect_service_via_amz_target() {
1683 let mut headers = HeaderMap::new();
1684 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1685 let query = HashMap::new();
1686 let body = Bytes::new();
1687 let detected = detect_service(&headers, &query, &body).unwrap();
1688 assert_eq!(detected.service, "ssm");
1689 assert_eq!(detected.action, "GetParameter");
1690 }
1691
1692 #[test]
1693 fn detect_service_via_query_action_with_inferred_service() {
1694 let headers = HeaderMap::new();
1695 let mut query = HashMap::new();
1696 query.insert("Action".to_string(), "AssumeRole".to_string());
1697 let body = Bytes::new();
1698 let detected = detect_service(&headers, &query, &body).unwrap();
1699 assert_eq!(detected.service, "sts");
1700 assert_eq!(detected.action, "AssumeRole");
1701 assert_eq!(detected.protocol, AwsProtocol::Query);
1702 }
1703
1704 #[test]
1705 fn detect_service_via_form_body() {
1706 let mut headers = HeaderMap::new();
1707 headers.insert(
1708 "content-type",
1709 "Application/X-WWW-Form-Urlencoded ; charset=UTF-8"
1710 .parse()
1711 .unwrap(),
1712 );
1713 let query = HashMap::new();
1714 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1715 let detected = detect_service(&headers, &query, &body).unwrap();
1716 assert_eq!(detected.service, "ses");
1717 assert_eq!(detected.action, "SendEmail");
1718 for content_type in [None, Some("text/plain"), Some("application/json")] {
1721 let mut headers = HeaderMap::new();
1722 if let Some(ct) = content_type {
1723 headers.insert("content-type", ct.parse().unwrap());
1724 }
1725 assert!(
1726 detect_service(&headers, &query, &body).is_none(),
1727 "{content_type:?}"
1728 );
1729 }
1730 }
1731
1732 #[test]
1733 fn detect_service_via_sigv2_presigned() {
1734 let headers = HeaderMap::new();
1735 let mut query = HashMap::new();
1736 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1737 query.insert("Signature".to_string(), "sig".to_string());
1738 query.insert("Expires".to_string(), "1234567890".to_string());
1739 let body = Bytes::new();
1740 let detected = detect_service(&headers, &query, &body).unwrap();
1741 assert_eq!(detected.service, "s3");
1742 assert_eq!(detected.protocol, AwsProtocol::Rest);
1743 }
1744
1745 #[test]
1746 fn detect_service_via_sigv4_presigned_credential() {
1747 let headers = HeaderMap::new();
1748 let mut query = HashMap::new();
1749 query.insert(
1750 "X-Amz-Credential".to_string(),
1751 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1752 );
1753 let body = Bytes::new();
1754 let detected = detect_service(&headers, &query, &body).unwrap();
1755 assert_eq!(detected.service, "s3");
1756 assert_eq!(detected.protocol, AwsProtocol::Rest);
1757 }
1758
1759 #[test]
1760 fn detect_service_unknown_returns_none() {
1761 let headers = HeaderMap::new();
1762 let query = HashMap::new();
1763 let body = Bytes::new();
1764 assert!(detect_service(&headers, &query, &body).is_none());
1765 }
1766
1767 #[test]
1768 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1769 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1774 }
1775
1776 #[test]
1777 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1778 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1783 }
1784
1785 #[test]
1786 fn normalize_service_name_passes_through_unaliased_services() {
1787 assert_eq!(normalize_service_name("bedrock"), "bedrock");
1791 assert_eq!(normalize_service_name("s3"), "s3");
1792 assert_eq!(normalize_service_name("lambda"), "lambda");
1793 assert_eq!(normalize_service_name(""), "");
1794 assert_eq!(
1795 normalize_service_name("unknown-future-service"),
1796 "unknown-future-service"
1797 );
1798 }
1799
1800 #[test]
1801 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1802 let mut headers = HeaderMap::new();
1807 headers.insert(
1808 "authorization",
1809 "AWS4-HMAC-SHA256 \
1810 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1811 SignedHeaders=host, Signature=abc"
1812 .parse()
1813 .unwrap(),
1814 );
1815 let query = HashMap::new();
1816 let body = Bytes::new();
1817 let detected = detect_service(&headers, &query, &body).unwrap();
1818 assert_eq!(detected.service, "bedrock");
1819 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1820 }
1821
1822 #[test]
1823 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1824 let headers = HeaderMap::new();
1828 let mut query = HashMap::new();
1829 query.insert(
1830 "X-Amz-Credential".to_string(),
1831 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1832 );
1833 let body = Bytes::new();
1834 let detected = detect_service(&headers, &query, &body).unwrap();
1835 assert_eq!(detected.service, "bedrock");
1836 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1837 }
1838
1839 #[test]
1840 fn normalize_host_strips_port_and_case() {
1841 assert_eq!(normalize_host("Rest.Example.com:4566"), "rest.example.com");
1842 assert_eq!(normalize_host("rest.example.com."), "rest.example.com");
1843 assert_eq!(normalize_host("rest.example.com"), "rest.example.com");
1844 assert_eq!(normalize_host("[::1]:4566"), "[::1]");
1845 assert_eq!(normalize_host("localhost"), "localhost");
1846 }
1847
1848 #[test]
1849 fn parse_routing_host_execute_api_hosts() {
1850 assert_eq!(
1853 parse_routing_host("abc123.execute-api.localhost.localstack.cloud:4566"),
1854 None
1855 );
1856 let h =
1857 parse_routing_host("abc123.execute-api.eu-west-1.localhost.localstack.cloud").unwrap();
1858 assert_eq!(h.service, "execute-api");
1859 assert_eq!(h.region, "eu-west-1");
1860 let h = parse_routing_host("abc123.execute-api.us-west-2.amazonaws.com").unwrap();
1861 assert_eq!(h.service, "execute-api");
1862 assert_eq!(h.region, "us-west-2");
1863 assert_eq!(h.bucket, None);
1864 }
1865
1866 #[test]
1867 fn detect_rpc_v2_cbor_requests() {
1868 let mut headers = HeaderMap::new();
1869 let path = "/service/GraniteServiceVersion20100801/operation/GetMetricData";
1870 assert!(detect_rpc_v2_cbor(&headers, path).is_none());
1872 headers.insert("smithy-protocol", "rpc-v2-cbor".parse().unwrap());
1873 let d = detect_rpc_v2_cbor(&headers, path).unwrap();
1874 assert_eq!(d.service, "monitoring");
1875 assert_eq!(d.action, "GetMetricData");
1876 assert_eq!(d.protocol, AwsProtocol::RpcV2Cbor);
1877 assert!(detect_rpc_v2_cbor(&headers, "/service/Unknown/operation/Op").is_none());
1878 assert!(detect_rpc_v2_cbor(&headers, "/prod/items").is_none());
1879 }
1880
1881 #[test]
1882 fn parse_routing_host_localstack_basic() {
1883 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1884 assert_eq!(h.service, "sqs");
1885 assert_eq!(h.region, "us-east-1");
1886 assert!(h.bucket.is_none());
1887 }
1888
1889 #[test]
1890 fn parse_routing_host_localstack_with_port() {
1891 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1892 assert_eq!(h.service, "lambda");
1893 assert_eq!(h.region, "eu-west-1");
1894 assert!(h.bucket.is_none());
1895 }
1896
1897 #[test]
1898 fn parse_routing_host_case_insensitive() {
1899 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1900 assert_eq!(h.service, "sqs");
1901 assert_eq!(h.region, "us-east-1");
1902
1903 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1904 assert_eq!(h.service, "lambda");
1905 assert_eq!(h.region, "us-east-1");
1906 }
1907
1908 #[test]
1909 fn parse_routing_host_localstack_s3_virtual_hosted() {
1910 let h =
1911 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1912 assert_eq!(h.service, "s3");
1913 assert_eq!(h.region, "us-east-1");
1914 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1915 }
1916
1917 #[test]
1918 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1919 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1920 assert_eq!(h.service, "s3");
1921 assert_eq!(h.region, "us-east-1");
1922 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1923 }
1924
1925 #[test]
1926 fn parse_routing_host_aws_service_region() {
1927 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1928 assert_eq!(h.service, "sqs");
1929 assert_eq!(h.region, "us-east-1");
1930 assert!(h.bucket.is_none());
1931
1932 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1933 assert_eq!(h.service, "dynamodb");
1934 assert_eq!(h.region, "eu-west-2");
1935 }
1936
1937 #[test]
1938 fn parse_routing_host_aws_s3_path_style_modern() {
1939 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1940 assert_eq!(h.service, "s3");
1941 assert_eq!(h.region, "us-east-1");
1942 assert!(h.bucket.is_none());
1943 }
1944
1945 #[test]
1946 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1947 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1948 assert_eq!(h.service, "s3");
1949 assert_eq!(h.region, "us-east-1");
1950 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1951 }
1952
1953 #[test]
1954 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1955 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1956 assert_eq!(h.service, "s3");
1957 assert_eq!(h.region, "us-east-1");
1958 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1959 }
1960
1961 #[test]
1962 fn parse_routing_host_aws_s3_legacy_global() {
1963 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1966 assert_eq!(h.service, "s3");
1967 assert_eq!(h.region, "us-east-1");
1968 assert!(h.bucket.is_none());
1969
1970 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1971 assert_eq!(h.service, "s3");
1972 assert_eq!(h.region, "us-east-1");
1973 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1974 }
1975
1976 #[test]
1977 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1978 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1981 assert_eq!(h.service, "s3");
1982 assert_eq!(h.region, "us-east-1");
1983 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1984 }
1985
1986 #[test]
1987 fn query_string_action_is_a_parameter_only_of_s3_and_api_gateway() {
1988 let detect = |host: Option<&str>, auth: Option<&str>, query: &[(&str, &str)]| {
1989 let mut headers = HeaderMap::new();
1990 if let Some(host) = host {
1991 headers.insert(http::header::HOST, host.parse().unwrap());
1992 }
1993 if let Some(auth) = auth {
1994 headers.insert(http::header::AUTHORIZATION, auth.parse().unwrap());
1995 }
1996 let params: HashMap<String, String> = query
1997 .iter()
1998 .map(|(k, v)| (k.to_string(), v.to_string()))
1999 .collect();
2000 let full =
2001 detect_service(&headers, ¶ms, &Bytes::new()).map(|d| (d.service, d.action));
2002 let head =
2003 detect_service_headers_only(&headers, ¶ms).map(|d| (d.service, d.action));
2004 assert_eq!(full, head, "{host:?} {query:?}");
2005 full
2006 };
2007 let s = |svc: &str, action: &str| Some((svc.to_string(), action.to_string()));
2008 assert_eq!(
2011 detect(None, None, &[("Action", "ListQueues")]),
2012 s("sqs", "ListQueues")
2013 );
2014 assert_eq!(
2016 detect(
2017 None,
2018 Some("AWS4-HMAC-SHA256 Credential=AKID/20261113/us-east-1/ses/aws4_request, SignedHeaders=host, Signature=x"),
2019 &[("Action", "ListIdentities")]
2020 ),
2021 s("ses", "ListIdentities")
2022 );
2023 assert_eq!(
2024 detect(
2025 Some("email.us-east-1.amazonaws.com"),
2026 None,
2027 &[("Action", "ListIdentities")]
2028 ),
2029 s("ses", "ListIdentities")
2030 );
2031 assert_eq!(
2032 detect(
2033 None,
2034 None,
2035 &[
2036 ("Action", "ListIdentities"),
2037 (
2038 "X-Amz-Credential",
2039 "AKID/20261113/us-east-1/ses/aws4_request"
2040 ),
2041 ]
2042 ),
2043 s("ses", "ListIdentities")
2044 );
2045 for (action, service) in [("ListQueues", "sqs"), ("DescribeRegions", "ec2")] {
2048 assert_eq!(
2049 detect(
2050 None,
2051 None,
2052 &[
2053 ("Action", action),
2054 ("AWSAccessKeyId", "AKID"),
2055 ("Signature", "x"),
2056 ("SignatureVersion", "2"),
2057 ("SignatureMethod", "HmacSHA256"),
2058 ("Timestamp", "2026-11-13T00:00:00Z"),
2059 ]
2060 ),
2061 s(service, action)
2062 );
2063 }
2064 assert_eq!(
2065 detect(
2066 None,
2067 None,
2068 &[
2069 ("Action", "ListQueues"),
2070 ("AWSAccessKeyId", "AKID"),
2071 ("Signature", "x"),
2072 ("Expires", "1900000000"),
2073 ]
2074 ),
2075 s("s3", "")
2076 );
2077 assert_eq!(
2079 detect(
2080 Some("pubb.s3.us-east-1.amazonaws.com"),
2081 None,
2082 &[("Action", "DescribeRegions")]
2083 ),
2084 s("s3", "")
2085 );
2086 assert_eq!(
2087 detect(
2088 Some("abc123.execute-api.us-east-1.amazonaws.com"),
2089 None,
2090 &[("Action", "Publish")]
2091 ),
2092 None,
2093 "an invocation: dispatch hands it to API Gateway"
2094 );
2095 assert_eq!(
2096 detect(
2097 None,
2098 None,
2099 &[
2100 ("Action", "ListQueues"),
2101 (
2102 "X-Amz-Credential",
2103 "AKID/20261113/us-east-1/s3/aws4_request"
2104 ),
2105 ]
2106 ),
2107 s("s3", "")
2108 );
2109 assert_eq!(
2110 detect(
2111 None,
2112 Some("AWS4-HMAC-SHA256 Credential=AKID/20261113/us-east-1/s3/aws4_request, SignedHeaders=host, Signature=x"),
2113 &[("Action", "ListTopics")]
2114 ),
2115 s("s3", "")
2116 );
2117 assert_eq!(
2119 detect(
2120 Some("sqs.us-east-1.amazonaws.com"),
2121 None,
2122 &[("Action", "ListQueues")]
2123 ),
2124 s("sqs", "ListQueues")
2125 );
2126 assert_eq!(
2127 detect(
2128 None,
2129 None,
2130 &[
2131 ("Action", "GetCallerIdentity"),
2132 (
2133 "X-Amz-Credential",
2134 "AKID/20261113/us-east-1/sts/aws4_request"
2135 ),
2136 ]
2137 ),
2138 s("sts", "GetCallerIdentity")
2139 );
2140 }
2141
2142 #[test]
2143 fn query_action_table_matches_the_models() {
2144 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../aws-models");
2147 let mut owners: std::collections::BTreeMap<String, std::collections::BTreeSet<String>> =
2148 Default::default();
2149 for entry in std::fs::read_dir(&dir).unwrap() {
2150 let path = entry.unwrap().path();
2151 let text = std::fs::read_to_string(&path).unwrap();
2152 if !["awsQuery\"", "ec2Query\"", "awsQueryCompatible\""]
2153 .iter()
2154 .any(|p| text.contains(&format!("aws.protocols#{p}")))
2155 {
2156 continue;
2157 }
2158 let model: serde_json::Value = serde_json::from_str(&text).unwrap();
2159 let shapes = model["shapes"].as_object().unwrap();
2160 let service = shapes.values().find(|s| s["type"] == "service").unwrap();
2161 let traits = service["traits"].as_object().unwrap();
2162 if ![
2163 "aws.protocols#awsQuery",
2164 "aws.protocols#ec2Query",
2165 "aws.protocols#awsQueryCompatible",
2166 ]
2167 .iter()
2168 .any(|p| traits.contains_key(*p))
2169 {
2170 continue;
2171 }
2172 let signing = traits["aws.auth#sigv4"]["name"].as_str().unwrap();
2173 for (id, shape) in shapes {
2174 if shape["type"] == "operation" {
2175 let op = id.split_once('#').unwrap().1;
2176 owners
2177 .entry(op.to_string())
2178 .or_default()
2179 .insert(signing.to_string());
2180 }
2181 }
2182 }
2183 let derived: Vec<(String, String)> = owners
2184 .into_iter()
2185 .filter(|(_, s)| s.len() == 1)
2186 .map(|(op, s)| (op, s.into_iter().next().unwrap()))
2187 .collect();
2188 let table: Vec<(String, String)> = query_actions::QUERY_ACTION_SERVICES
2189 .iter()
2190 .map(|(a, s)| (a.to_string(), s.to_string()))
2191 .collect();
2192 assert_eq!(
2193 table, derived,
2194 "rerun scripts/generate-query-action-services.py"
2195 );
2196 }
2197
2198 #[test]
2199 fn parse_routing_host_email_is_ses() {
2200 for host in [
2201 "email.us-east-1.amazonaws.com",
2202 "email.eu-west-1.localhost.localstack.cloud:4566",
2203 ] {
2204 assert_eq!(parse_routing_host(host).unwrap().service, "ses", "{host}");
2205 }
2206 }
2207
2208 #[test]
2209 fn parse_routing_host_global_endpoints() {
2210 for service in ["iam", "sts", "route53", "cloudfront"] {
2211 let h = parse_routing_host(&format!("{service}.amazonaws.com:443")).unwrap();
2212 assert_eq!(h.service, service);
2213 assert_eq!(h.region, "us-east-1");
2214 assert_eq!(h.bucket, None);
2215 }
2216 assert!(parse_routing_host("lambda.amazonaws.com").is_none());
2218 assert!(parse_routing_host("route53.amazonaws.com.cn").is_none());
2219 }
2220
2221 #[test]
2222 fn parse_routing_host_aws_s3_dash_separated() {
2223 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
2225 assert_eq!(h.service, "s3");
2226 assert_eq!(h.region, "us-west-2");
2227 assert!(h.bucket.is_none());
2228
2229 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
2230 assert_eq!(h.service, "s3");
2231 assert_eq!(h.region, "us-west-2");
2232 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
2233 }
2234
2235 #[test]
2236 fn parse_routing_host_aws_s3_dualstack_and_fips() {
2237 for (host, bucket) in [
2238 (
2239 "my-bucket.s3.dualstack.us-east-1.amazonaws.com",
2240 Some("my-bucket"),
2241 ),
2242 ("a.b.s3.dualstack.eu-west-2.amazonaws.com", Some("a.b")),
2243 ("s3.dualstack.eu-west-2.amazonaws.com", None),
2244 (
2245 "my-bucket.s3-fips.us-gov-west-1.amazonaws.com",
2246 Some("my-bucket"),
2247 ),
2248 (
2249 "my-bucket.s3-fips.dualstack.us-east-1.amazonaws.com",
2250 Some("my-bucket"),
2251 ),
2252 ] {
2253 let h = parse_routing_host(host).unwrap();
2254 assert_eq!(h.service, "s3", "{host}");
2255 assert_eq!(h.bucket.as_deref(), bucket, "{host}");
2256 }
2257 let h = parse_routing_host("b.s3.dualstack.eu-west-2.amazonaws.com").unwrap();
2258 assert_eq!(h.region, "eu-west-2");
2259 }
2260
2261 #[test]
2262 fn parse_routing_host_aws_s3_website_endpoints() {
2263 let h = parse_routing_host("site.s3-website-us-west-2.amazonaws.com").unwrap();
2266 assert_eq!(h.service, "s3");
2267 assert_eq!(h.region, "us-west-2");
2268 assert_eq!(h.bucket.as_deref(), Some("site"));
2269
2270 let h = parse_routing_host("my.site.s3-website.eu-central-1.amazonaws.com").unwrap();
2272 assert_eq!(h.service, "s3");
2273 assert_eq!(h.region, "eu-central-1");
2274 assert_eq!(h.bucket.as_deref(), Some("my.site"));
2275 }
2276
2277 #[test]
2278 fn parse_routing_host_other_partition_suffixes() {
2279 let h = parse_routing_host("my-bucket.s3.cn-north-1.amazonaws.com.cn").unwrap();
2280 assert_eq!(h.service, "s3");
2281 assert_eq!(h.region, "cn-north-1");
2282 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
2283
2284 let h = parse_routing_host("sqs.cn-northwest-1.amazonaws.com.cn").unwrap();
2285 assert_eq!(h.service, "sqs");
2286 assert_eq!(h.region, "cn-northwest-1");
2287
2288 let h = parse_routing_host("b.s3.us-iso-east-1.c2s.ic.gov").unwrap();
2289 assert_eq!(h.region, "us-iso-east-1");
2290 assert_eq!(h.bucket.as_deref(), Some("b"));
2291
2292 assert!(parse_routing_host("amazonaws.com.cn").is_none());
2293 assert!(parse_routing_host(".amazonaws.com.cn").is_none());
2294 }
2295
2296 #[test]
2297 fn parse_routing_host_rejects_plain_localhost() {
2298 assert!(parse_routing_host("localhost:4566").is_none());
2299 assert!(parse_routing_host("127.0.0.1:4566").is_none());
2300 }
2301
2302 #[test]
2303 fn parse_routing_host_rejects_unknown_suffix() {
2304 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
2305 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
2306 }
2307
2308 #[test]
2309 fn parse_routing_host_empty_and_malformed_rejected() {
2310 assert!(parse_routing_host("").is_none());
2311 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
2312 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
2313 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
2314 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
2315 assert!(parse_routing_host(".amazonaws.com").is_none());
2316 assert!(parse_routing_host("amazonaws.com").is_none());
2317 }
2318
2319 #[test]
2320 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
2321 assert!(parse_routing_host("s3-accesspoint").is_none());
2325 }
2326
2327 #[test]
2328 fn detect_service_via_host_for_rest_service() {
2329 let mut headers = HeaderMap::new();
2330 headers.insert(
2331 "host",
2332 "s3.us-east-1.localhost.localstack.cloud:4566"
2333 .parse()
2334 .unwrap(),
2335 );
2336 let query = HashMap::new();
2337 let body = Bytes::new();
2338 let detected = detect_service(&headers, &query, &body).unwrap();
2339 assert_eq!(detected.service, "s3");
2340 assert_eq!(detected.protocol, AwsProtocol::Rest);
2341 }
2342
2343 #[test]
2344 fn detect_service_via_host_for_rest_json_service() {
2345 let mut headers = HeaderMap::new();
2346 headers.insert(
2347 "host",
2348 "lambda.us-east-1.localhost.localstack.cloud:4566"
2349 .parse()
2350 .unwrap(),
2351 );
2352 let query = HashMap::new();
2353 let body = Bytes::new();
2354 let detected = detect_service(&headers, &query, &body).unwrap();
2355 assert_eq!(detected.service, "lambda");
2356 assert_eq!(detected.protocol, AwsProtocol::RestJson);
2357 }
2358
2359 #[test]
2360 fn detect_service_via_host_plus_query_action() {
2361 let mut headers = HeaderMap::new();
2362 headers.insert(
2363 "host",
2364 "sqs.us-east-1.localhost.localstack.cloud:4566"
2365 .parse()
2366 .unwrap(),
2367 );
2368 let mut query = HashMap::new();
2369 query.insert("Action".to_string(), "ListQueues".to_string());
2370 let body = Bytes::new();
2371 let detected = detect_service(&headers, &query, &body).unwrap();
2372 assert_eq!(detected.service, "sqs");
2373 assert_eq!(detected.action, "ListQueues");
2374 assert_eq!(detected.protocol, AwsProtocol::Query);
2375 }
2376
2377 #[test]
2378 fn detect_service_sigv4_wins_over_host() {
2379 let mut headers = HeaderMap::new();
2380 headers.insert(
2381 "authorization",
2382 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
2383 SignedHeaders=host, Signature=abc"
2384 .parse()
2385 .unwrap(),
2386 );
2387 headers.insert(
2388 "host",
2389 "lambda.us-east-1.localhost.localstack.cloud:4566"
2390 .parse()
2391 .unwrap(),
2392 );
2393 let query = HashMap::new();
2394 let body = Bytes::new();
2395 let detected = detect_service(&headers, &query, &body).unwrap();
2396 assert_eq!(detected.service, "s3");
2398 assert_eq!(detected.protocol, AwsProtocol::Rest);
2399 }
2400
2401 #[test]
2402 fn detect_service_host_for_virtual_hosted_s3() {
2403 let mut headers = HeaderMap::new();
2404 headers.insert(
2405 "host",
2406 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
2407 .parse()
2408 .unwrap(),
2409 );
2410 let query = HashMap::new();
2411 let body = Bytes::new();
2412 let detected = detect_service(&headers, &query, &body).unwrap();
2413 assert_eq!(detected.service, "s3");
2414 assert_eq!(detected.protocol, AwsProtocol::Rest);
2415 }
2416}