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
158#[derive(Debug, Clone)]
160pub struct DetectedRequest {
161 pub service: String,
162 pub action: String,
163 pub protocol: AwsProtocol,
164}
165
166pub fn detect_service_headers_only(
173 headers: &HeaderMap,
174 query_params: &HashMap<String, String>,
175) -> Option<DetectedRequest> {
176 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
178 return parse_amz_target(target);
179 }
180 if let Some(action) = query_params.get("Action") {
181 let service = extract_service_from_auth(headers)
182 .or_else(|| infer_service_from_action(action))
183 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
184 if let Some(service) = service {
185 let protocol = query_protocol_for(&service);
186 return Some(DetectedRequest {
187 service,
188 action: action.clone(),
189 protocol,
190 });
191 }
192 }
193 if let Some(service) = extract_service_from_auth(headers) {
194 if let Some(protocol) = rest_protocol_for(&service) {
195 return Some(DetectedRequest {
196 service,
197 action: String::new(),
198 protocol,
199 });
200 }
201 }
202 if let Some(credential) = query_params.get("X-Amz-Credential") {
203 let parts: Vec<&str> = credential.split('/').collect();
204 if parts.len() >= 4 {
205 let service = normalize_service_name(parts[3]).to_string();
206 if let Some(protocol) = rest_protocol_for(&service) {
207 return Some(DetectedRequest {
208 service,
209 action: String::new(),
210 protocol,
211 });
212 }
213 }
214 }
215 if query_params.contains_key("AWSAccessKeyId")
216 && query_params.contains_key("Signature")
217 && query_params.contains_key("Expires")
218 {
219 return Some(DetectedRequest {
220 service: "s3".to_string(),
221 action: String::new(),
222 protocol: AwsProtocol::Rest,
223 });
224 }
225 if let Some(host_info) = parse_routing_host_from_headers(headers) {
226 if let Some(protocol) = rest_protocol_for(&host_info.service) {
227 return Some(DetectedRequest {
228 service: host_info.service,
229 action: String::new(),
230 protocol,
231 });
232 }
233 }
234 None
235}
236
237pub fn detect_service(
239 headers: &HeaderMap,
240 query_params: &HashMap<String, String>,
241 body: &Bytes,
242) -> Option<DetectedRequest> {
243 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
245 return parse_amz_target(target);
246 }
247
248 if let Some(action) = query_params.get("Action") {
250 let service = extract_service_from_auth(headers)
251 .or_else(|| infer_service_from_action(action))
252 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
253 if let Some(service) = service {
254 let protocol = query_protocol_for(&service);
255 return Some(DetectedRequest {
256 service,
257 action: action.clone(),
258 protocol,
259 });
260 }
261 }
262
263 {
265 let form_params = decode_form_urlencoded(body);
266
267 if let Some(action) = form_params.get("Action") {
268 let service = extract_service_from_auth(headers)
269 .or_else(|| infer_service_from_action(action))
270 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
271 if let Some(service) = service {
272 let protocol = query_protocol_for(&service);
273 return Some(DetectedRequest {
274 service,
275 action: action.clone(),
276 protocol,
277 });
278 }
279 }
280 }
281
282 if let Some(service) = extract_service_from_auth(headers) {
284 if let Some(protocol) = rest_protocol_for(&service) {
285 return Some(DetectedRequest {
286 service,
287 action: String::new(), protocol,
289 });
290 }
291 }
292
293 if let Some(credential) = query_params.get("X-Amz-Credential") {
295 let parts: Vec<&str> = credential.split('/').collect();
297 if parts.len() >= 4 {
298 let service = normalize_service_name(parts[3]).to_string();
299 if let Some(protocol) = rest_protocol_for(&service) {
300 return Some(DetectedRequest {
301 service,
302 action: String::new(),
303 protocol,
304 });
305 }
306 }
307 }
308
309 if query_params.contains_key("AWSAccessKeyId")
313 && query_params.contains_key("Signature")
314 && query_params.contains_key("Expires")
315 {
316 return Some(DetectedRequest {
317 service: "s3".to_string(),
318 action: String::new(),
319 protocol: AwsProtocol::Rest,
320 });
321 }
322
323 if let Some(host_info) = parse_routing_host_from_headers(headers) {
327 if let Some(protocol) = rest_protocol_for(&host_info.service) {
328 return Some(DetectedRequest {
329 service: host_info.service,
330 action: String::new(),
331 protocol,
332 });
333 }
334 }
335
336 None
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
349pub struct RoutingHost {
350 pub service: String,
351 pub region: String,
352 pub bucket: Option<String>,
354}
355
356const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
357
358pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
362 let hostname = host.split(':').next()?;
363 if hostname.is_empty() {
364 return None;
365 }
366 let hostname = hostname.to_ascii_lowercase();
367 if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
368 return parse_localstack_prefix(prefix);
369 }
370 fakecloud_aws::endpoint::DNS_SUFFIXES
373 .iter()
374 .find_map(|suffix| hostname.strip_suffix(suffix)?.strip_suffix('.'))
375 .and_then(parse_aws_prefix)
376}
377
378pub fn is_s3_control_host(headers: &HeaderMap) -> bool {
384 let Some(host) = headers.get("host").and_then(|v| v.to_str().ok()) else {
385 return false;
386 };
387 let hostname = host.split(':').next().unwrap_or("").to_ascii_lowercase();
388 let labels: Vec<&str> = hostname.split('.').collect();
389 let control_label = match labels.as_slice() {
390 ["s3-control", ..] => true,
391 [account, "s3-control", ..] => {
392 account.len() == 12 && account.bytes().all(|b| b.is_ascii_digit())
393 }
394 _ => false,
395 };
396 control_label && parse_routing_host(host).is_none_or(|h| h.bucket.is_none())
397}
398
399pub fn normalize_host(host: &str) -> String {
404 let host = host.trim();
405 let without_port = if let Some(rest) = host.strip_prefix('[') {
406 match rest.find(']') {
407 Some(end) => &host[..end + 2],
408 None => host,
409 }
410 } else {
411 match host.rsplit_once(':') {
412 Some((name, port)) if port.chars().all(|c| c.is_ascii_digit()) => name,
413 _ => host,
414 }
415 };
416 without_port.trim_end_matches('.').to_ascii_lowercase()
417}
418
419pub fn normalized_host_from_headers(headers: &HeaderMap) -> Option<String> {
421 let host = headers.get("host")?.to_str().ok()?;
422 let host = normalize_host(host);
423 (!host.is_empty()).then_some(host)
424}
425
426pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
428 let host = headers.get("host")?.to_str().ok()?;
429 parse_routing_host(host)
430}
431
432fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
433 if prefix.is_empty() {
434 return None;
435 }
436 let labels: Vec<&str> = prefix.split('.').collect();
437 if labels.iter().any(|l| l.is_empty()) {
438 return None;
439 }
440 if let Some(host) = parse_execute_api_labels(&labels) {
445 return host;
446 }
447 match labels.len() {
448 2 => Some(RoutingHost {
449 service: labels[0].to_string(),
450 region: labels[1].to_string(),
451 bucket: None,
452 }),
453 n if n >= 3 && labels[n - 2] == "s3" => {
454 let bucket = labels[..n - 2].join(".");
455 Some(RoutingHost {
456 service: "s3".to_string(),
457 region: labels[n - 1].to_string(),
458 bucket: Some(bucket),
459 })
460 }
461 n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
462 let bucket = labels[..n - 2].join(".");
463 Some(RoutingHost {
464 service: "s3".to_string(),
465 region: labels[n - 1].to_string(),
466 bucket: Some(bucket),
467 })
468 }
469 n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
470 service: "s3".to_string(),
471 region: labels[n - 1].to_string(),
472 bucket: None,
473 }),
474 _ => None,
475 }
476}
477
478fn parse_execute_api_labels(labels: &[&str]) -> Option<Option<RoutingHost>> {
483 match labels {
484 [_api_id, "execute-api"] => Some(None),
485 [_api_id, "execute-api", region] => Some(Some(RoutingHost {
486 service: "execute-api".to_string(),
487 region: region.to_string(),
488 bucket: None,
489 })),
490 _ => None,
491 }
492}
493
494fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
510 if prefix.is_empty() {
511 return None;
512 }
513 let labels: Vec<&str> = prefix.split('.').collect();
514 if labels.iter().any(|l| l.is_empty()) {
515 return None;
516 }
517 let last = *labels.last()?;
518
519 if let Some(region) = last.strip_prefix("s3-website-") {
523 if !region.is_empty() && labels.len() >= 2 {
524 return Some(RoutingHost {
525 service: "s3".to_string(),
526 region: region.to_string(),
527 bucket: Some(labels[..labels.len() - 1].join(".")),
528 });
529 }
530 }
531
532 if let Some(region) = last.strip_prefix("s3-") {
535 if !region.is_empty() {
536 let bucket = if labels.len() >= 2 {
537 Some(labels[..labels.len() - 1].join("."))
538 } else {
539 None
540 };
541 return Some(RoutingHost {
542 service: "s3".to_string(),
543 region: region.to_string(),
544 bucket,
545 });
546 }
547 }
548
549 if last == "s3" {
553 if labels.len() == 1 {
554 return Some(RoutingHost {
555 service: "s3".to_string(),
556 region: "us-east-1".to_string(),
557 bucket: None,
558 });
559 }
560 return Some(RoutingHost {
561 service: "s3".to_string(),
562 region: "us-east-1".to_string(),
563 bucket: Some(labels[..labels.len() - 1].join(".")),
564 });
565 }
566
567 if last == "s3-accesspoint" {
570 if labels.len() == 2 {
571 return Some(RoutingHost {
572 service: "s3".to_string(),
573 region: labels[0].to_string(),
574 bucket: None,
575 });
576 }
577 if labels.len() >= 3 {
581 let bucket = labels[..labels.len() - 2].join(".");
582 return Some(RoutingHost {
583 service: "s3".to_string(),
584 region: labels[labels.len() - 1].to_string(),
585 bucket: Some(bucket),
586 });
587 }
588 }
589
590 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
593 return Some(RoutingHost {
594 service: "s3".to_string(),
595 region: last.to_string(),
596 bucket: None,
597 });
598 }
599
600 let before_region = &labels[..labels.len() - 1];
605 let (endpoint_labels, s3_endpoint) = match before_region {
606 [rest @ .., s3, "dualstack"] if matches!(*s3, "s3" | "s3-fips") => (rest, true),
607 [rest @ .., endpoint] if matches!(*endpoint, "s3" | "s3-fips" | "s3-website") => {
608 (rest, true)
609 }
610 _ => (before_region, false),
611 };
612 if s3_endpoint {
613 return Some(RoutingHost {
614 service: "s3".to_string(),
615 region: last.to_string(),
616 bucket: (!endpoint_labels.is_empty()).then(|| endpoint_labels.join(".")),
617 });
618 }
619
620 if let Some(host) = parse_execute_api_labels(&labels) {
622 return host;
623 }
624
625 match labels.as_slice() {
627 [service, region] => Some(RoutingHost {
628 service: service.to_string(),
629 region: region.to_string(),
630 bucket: None,
631 }),
632 _ => None,
633 }
634}
635
636pub fn detect_rpc_v2_cbor(headers: &HeaderMap, path: &str) -> Option<DetectedRequest> {
641 let is_cbor = headers
642 .get(crate::cbor::SMITHY_PROTOCOL_HEADER)
643 .and_then(|v| v.to_str().ok())
644 .is_some_and(|v| v.trim().eq_ignore_ascii_case(crate::cbor::RPC_V2_CBOR));
645 if !is_cbor {
646 return None;
647 }
648 let (service_shape, operation) = crate::cbor::parse_rpc_v2_path(path)?;
649 let detected = parse_amz_target(&format!("{service_shape}.{operation}"))?;
650 Some(DetectedRequest {
651 protocol: AwsProtocol::RpcV2Cbor,
652 ..detected
653 })
654}
655
656pub(crate) fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
659 let (prefix, action) = target.rsplit_once('.')?;
660
661 let service = match prefix {
662 "AWSEvents" => "events",
663 "AmazonSSM" => "ssm",
664 "AmazonSQS" => "sqs",
665 "AmazonSNS" => "sns",
666 "DynamoDB_20120810" => "dynamodb",
667 "DynamoDBStreams_20120810" => "dynamodbstreams",
668 "Logs_20140328" => "logs",
669 s if s.starts_with("secretsmanager") => "secretsmanager",
670 s if s.starts_with("TrentService") => "kms",
671 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
672 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
673 s if s.starts_with("Kinesis_20131202") => "kinesis",
674 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
675 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
676 s if s.starts_with("AWSStepFunctions") => "states",
677 s if s.starts_with("AWSOrganizationsV") => "organizations",
678 "CertificateManager" => "acm",
679 "ACMPrivateCA" => "acm-pca",
680 "Route53Resolver" => "route53resolver",
684 "StarlingDoveService" => "config",
687 "AnyScaleFrontendService" => "application-autoscaling",
688 "AWSWAF_20190729" => "wafv2",
691 "AmazonAthena" => "athena",
692 s if s.starts_with("Firehose_") => "firehose",
693 "AWSGlue" => "glue",
694 "ElasticMapReduce" => "emr",
697 "Textract" => "textract",
700 "Transcribe" => "transcribe",
703 "AWSShineFrontendService_20170701" => "translate",
706 "AWSShield_20160616" => "shield",
709 "Comprehend_20171127" => "comprehend",
712 "SimpleWorkflowService" => "swf",
715 "Timestream_20181101" => "timestream",
719 "AWSSupport_20130415" => "support",
722 "CloudApiService" => "cloudcontrolapi",
723 "ResourceGroupsTaggingAPI_20170126" => "tagging",
724 "AmazonMemoryDB" => "memorydb",
725 s if s.starts_with("KinesisAnalytics_20180523") => "kinesisanalyticsv2",
730 "Route53AutoNaming_v20170314" => "servicediscovery",
733 "AmazonDMSv20160101" => "dms",
735 "CloudTrail_20131101" => "cloudtrail",
739 "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
740 "AWSInsightsIndexService" => "ce",
744 "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
745 "TransferService" => "transfer",
747 "CodeBuild_20161006" => "codebuild",
749 "CodeCommit_20150413" => "codecommit",
751 "ServiceQuotasV20190624" => "servicequotas",
753 "AWSIdentityStore" => "identitystore",
755 "SWBExternalService" => "sso",
757 "VerifiedPermissions" => "verifiedpermissions",
759 "CodeConnections_20231201" => "codeconnections",
761 "CodeStar_connections_20191201" => "codeconnections",
767 "CodeDeploy_20141006" => "codedeploy",
769 "CodePipeline_20150709" => "codepipeline",
771 s if s.starts_with("GraniteServiceVersion") => "monitoring",
777 "SageMaker" => "sagemaker",
780 _ => return None,
781 };
782
783 Some(DetectedRequest {
784 service: service.to_string(),
785 action: action.to_string(),
786 protocol: AwsProtocol::Json,
787 })
788}
789
790fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
792 if REST_XML_SERVICES.contains(&service) {
793 Some(AwsProtocol::Rest)
794 } else if REST_JSON_SERVICES.contains(&service) {
795 Some(AwsProtocol::RestJson)
796 } else {
797 None
798 }
799}
800
801fn infer_service_from_action(action: &str) -> Option<String> {
805 match action {
806 "AssumeRole"
807 | "AssumeRoleWithSAML"
808 | "AssumeRoleWithWebIdentity"
809 | "GetCallerIdentity"
810 | "GetSessionToken"
811 | "GetFederationToken"
812 | "GetAccessKeyInfo"
813 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
814 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
815 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
816 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
817 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
818 "VerifyEmailIdentity"
820 | "VerifyDomainIdentity"
821 | "VerifyDomainDkim"
822 | "ListIdentities"
823 | "GetIdentityVerificationAttributes"
824 | "GetIdentityDkimAttributes"
825 | "DeleteIdentity"
826 | "SetIdentityDkimEnabled"
827 | "SetIdentityNotificationTopic"
828 | "SetIdentityFeedbackForwardingEnabled"
829 | "GetIdentityNotificationAttributes"
830 | "GetIdentityMailFromDomainAttributes"
831 | "SetIdentityMailFromDomain"
832 | "SendEmail"
833 | "SendRawEmail"
834 | "SendTemplatedEmail"
835 | "SendBulkTemplatedEmail"
836 | "CreateTemplate"
837 | "GetTemplate"
838 | "ListTemplates"
839 | "DeleteTemplate"
840 | "UpdateTemplate"
841 | "CreateConfigurationSet"
842 | "DeleteConfigurationSet"
843 | "DescribeConfigurationSet"
844 | "ListConfigurationSets"
845 | "CreateConfigurationSetEventDestination"
846 | "UpdateConfigurationSetEventDestination"
847 | "DeleteConfigurationSetEventDestination"
848 | "GetSendQuota"
849 | "GetSendStatistics"
850 | "GetAccountSendingEnabled"
851 | "CreateReceiptRuleSet"
852 | "DeleteReceiptRuleSet"
853 | "DescribeReceiptRuleSet"
854 | "ListReceiptRuleSets"
855 | "CloneReceiptRuleSet"
856 | "SetActiveReceiptRuleSet"
857 | "ReorderReceiptRuleSet"
858 | "CreateReceiptRule"
859 | "DeleteReceiptRule"
860 | "DescribeReceiptRule"
861 | "UpdateReceiptRule"
862 | "CreateReceiptFilter"
863 | "DeleteReceiptFilter"
864 | "ListReceiptFilters" => Some("ses".to_string()),
865 "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
869 _ => None,
870 }
871}
872
873pub(crate) fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
875 let auth = headers.get("authorization")?.to_str().ok()?;
876 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
877 Some(normalize_service_name(&info.service).to_string())
878}
879
880fn normalize_service_name(service: &str) -> &str {
892 match service {
893 "bedrock-runtime" => "bedrock",
894 "apigatewayv2" => "apigateway",
902 "opensearch" => "es",
910 "appconfigdata" => "appconfig",
916 "airflow" => "mwaa",
921 "mobiletargeting" => "pinpoint",
926 other => other,
927 }
928}
929
930pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
932 decode_form_urlencoded(body)
933}
934
935pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
952 let mut out = HashMap::new();
953 let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
954 return out;
955 };
956 if value.is_object() {
957 flatten_json_value("", &value, &mut out);
958 }
959 out
960}
961
962fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
963 match value {
964 serde_json::Value::Object(map) => {
965 for (k, v) in map {
966 let child = if prefix.is_empty() {
967 k.clone()
968 } else {
969 format!("{prefix}.{k}")
970 };
971 flatten_json_value(&child, v, out);
972 }
973 }
974 serde_json::Value::Array(items) => {
975 for (i, v) in items.iter().enumerate() {
976 let child = format!("{prefix}.member.{}", i + 1);
977 flatten_json_value(&child, v, out);
978 }
979 }
980 serde_json::Value::Null => {}
981 serde_json::Value::String(s) => {
982 out.insert(prefix.to_string(), s.clone());
983 }
984 serde_json::Value::Bool(b) => {
985 out.insert(prefix.to_string(), b.to_string());
986 }
987 serde_json::Value::Number(n) => {
988 out.insert(prefix.to_string(), n.to_string());
989 }
990 }
991}
992
993pub(crate) fn form_urlencoded_pairs(input: &str) -> Vec<(String, String)> {
999 let mut pairs = Vec::new();
1000 for pair in input.split('&') {
1001 if pair.is_empty() {
1002 continue;
1003 }
1004 let (key, value) = match pair.find('=') {
1005 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1006 None => (pair, ""),
1007 };
1008 pairs.push((url_decode(key), url_decode(value)));
1009 }
1010 pairs
1011}
1012
1013fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
1014 let s = std::str::from_utf8(input).unwrap_or("");
1015 let mut result = HashMap::new();
1016 for pair in s.split('&') {
1017 if pair.is_empty() {
1018 continue;
1019 }
1020 let (key, value) = match pair.find('=') {
1021 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
1022 None => (pair, ""),
1023 };
1024 result.insert(url_decode(key), url_decode(value));
1025 }
1026 result
1027}
1028
1029pub fn url_decode(input: &str) -> String {
1034 let mut buf: Vec<u8> = Vec::with_capacity(input.len());
1040 let mut bytes = input.bytes();
1041 while let Some(b) = bytes.next() {
1042 match b {
1043 b'+' => buf.push(b' '),
1044 b'%' => {
1045 let high = bytes.next().and_then(from_hex);
1046 let low = bytes.next().and_then(from_hex);
1047 if let (Some(h), Some(l)) = (high, low) {
1051 buf.push((h << 4) | l);
1052 }
1053 }
1054 _ => buf.push(b),
1055 }
1056 }
1057 String::from_utf8_lossy(&buf).into_owned()
1058}
1059
1060fn from_hex(b: u8) -> Option<u8> {
1061 match b {
1062 b'0'..=b'9' => Some(b - b'0'),
1063 b'a'..=b'f' => Some(b - b'a' + 10),
1064 b'A'..=b'F' => Some(b - b'A' + 10),
1065 _ => None,
1066 }
1067}
1068
1069#[cfg(test)]
1070mod tests {
1071 use super::*;
1072
1073 #[test]
1074 fn s3_control_host_requires_leading_control_label() {
1075 let is_control = |host: &str| {
1076 let mut headers = HeaderMap::new();
1077 headers.insert("host", host.parse().unwrap());
1078 is_s3_control_host(&headers)
1079 };
1080 assert!(is_control("s3-control.us-east-1.amazonaws.com"));
1081 assert!(is_control(
1082 "123456789012.s3-control.us-east-1.amazonaws.com"
1083 ));
1084 assert!(is_control(
1085 "000000000000.s3-control.us-east-1.localhost.localstack.cloud:4566"
1086 ));
1087 assert!(is_control("123456789012.s3-control.localhost"));
1088 assert!(!is_control("my-s3-control-logs.s3.us-east-1.amazonaws.com"));
1089 assert!(!is_control(
1090 "my-s3-control-logs.s3.us-east-1.localhost.localstack.cloud:4566"
1091 ));
1092 assert!(!is_control("a.s3-control.b.s3.us-east-1.amazonaws.com"));
1093 assert!(!is_control("localhost:4566"));
1094 }
1095
1096 #[test]
1097 fn form_urlencoded_pairs_preserves_repeated_keys() {
1098 let pairs = form_urlencoded_pairs("Id=a&Id=b&Id=c&Other=x");
1101 let ids: Vec<&str> = pairs
1102 .iter()
1103 .filter(|(k, _)| k == "Id")
1104 .map(|(_, v)| v.as_str())
1105 .collect();
1106 assert_eq!(ids, vec!["a", "b", "c"]);
1107 assert_eq!(
1109 decode_form_urlencoded(b"Id=a&Id=b&Id=c").get("Id").unwrap(),
1110 "c"
1111 );
1112 }
1113
1114 #[test]
1115 fn form_urlencoded_pairs_decodes_percent_and_plus() {
1116 let pairs = form_urlencoded_pairs("q=caf%C3%A9+bar&empty=");
1117 assert_eq!(pairs[0], ("q".to_string(), "café bar".to_string()));
1118 assert_eq!(pairs[1], ("empty".to_string(), String::new()));
1119 }
1120
1121 #[test]
1122 fn parse_amz_target_events() {
1123 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
1124 assert_eq!(result.service, "events");
1125 assert_eq!(result.action, "PutEvents");
1126 assert_eq!(result.protocol, AwsProtocol::Json);
1127 }
1128
1129 #[test]
1130 fn parse_amz_target_ssm() {
1131 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
1132 assert_eq!(result.service, "ssm");
1133 assert_eq!(result.action, "GetParameter");
1134 }
1135
1136 #[test]
1137 fn parse_amz_target_kinesis() {
1138 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
1139 assert_eq!(result.service, "kinesis");
1140 assert_eq!(result.action, "ListStreams");
1141 assert_eq!(result.protocol, AwsProtocol::Json);
1142 }
1143
1144 #[test]
1145 fn parse_query_body_basic() {
1146 let body = Bytes::from(
1147 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
1148 );
1149 let params = parse_query_body(&body);
1150 assert_eq!(params.get("Action").unwrap(), "SendMessage");
1151 assert_eq!(params.get("MessageBody").unwrap(), "hello");
1152 }
1153
1154 #[test]
1155 fn parse_query_body_empty_returns_empty_map() {
1156 let body = Bytes::from("");
1157 let params = parse_query_body(&body);
1158 assert!(params.is_empty());
1159 }
1160
1161 #[test]
1162 fn parse_query_body_duplicate_keys_last_wins() {
1163 let body = Bytes::from("key=a&key=b");
1164 let params = parse_query_body(&body);
1165 assert_eq!(params.get("key").unwrap(), "b");
1166 }
1167
1168 #[test]
1169 fn parse_query_body_single_key() {
1170 let body = Bytes::from("key=value");
1171 let params = parse_query_body(&body);
1172 assert_eq!(params.get("key").unwrap(), "value");
1173 }
1174
1175 #[test]
1176 fn url_decode_plain_ascii() {
1177 assert_eq!(url_decode("hello"), "hello");
1178 assert_eq!(url_decode("Action=SendMessage"), "Action=SendMessage");
1179 }
1180
1181 #[test]
1182 fn url_decode_plus_is_space() {
1183 assert_eq!(url_decode("hello+world"), "hello world");
1184 assert_eq!(url_decode("a+b+c"), "a b c");
1185 }
1186
1187 #[test]
1188 fn url_decode_multibyte_utf8_accents() {
1189 assert_eq!(url_decode("caf%C3%A9"), "café");
1191 }
1192
1193 #[test]
1194 fn url_decode_multibyte_utf8_cjk() {
1195 assert_eq!(url_decode("%E6%97%A5%E6%9C%AC"), "日本");
1197 }
1198
1199 #[test]
1200 fn url_decode_multibyte_utf8_emoji() {
1201 assert_eq!(url_decode("%F0%9F%9A%80"), "🚀");
1203 }
1204
1205 #[test]
1206 fn url_decode_mixed_ascii_and_multibyte() {
1207 assert_eq!(url_decode("Tag+%3D+caf%C3%A9%21"), "Tag = café!");
1208 }
1209
1210 #[test]
1211 fn url_decode_malformed_percent_is_graceful() {
1212 assert_eq!(url_decode("100%"), "100");
1214 assert_eq!(url_decode("a%zz"), "a");
1215 assert_eq!(url_decode("a%4"), "a");
1216 assert_eq!(url_decode("x%y"), "x");
1218 }
1219
1220 #[test]
1221 fn url_decode_invalid_utf8_bytes_are_lossy_no_panic() {
1222 let out = url_decode("bad%FFbyte");
1224 assert!(out.starts_with("bad"));
1225 assert!(out.ends_with("byte"));
1226 }
1227
1228 #[test]
1229 fn parse_query_body_multibyte_value_round_trips() {
1230 let body = Bytes::from("Tag.Value=caf%C3%A9&Name=%E6%97%A5%E6%9C%AC");
1231 let params = parse_query_body(&body);
1232 assert_eq!(params.get("Tag.Value").unwrap(), "café");
1233 assert_eq!(params.get("Name").unwrap(), "日本");
1234 }
1235
1236 #[test]
1237 fn parse_amz_target_ecs() {
1238 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
1239 assert_eq!(result.service, "ecs");
1240 assert_eq!(result.action, "ListClusters");
1241 assert_eq!(result.protocol, AwsProtocol::Json);
1242 }
1243
1244 #[test]
1245 fn parse_amz_target_invalid_returns_none() {
1246 assert!(parse_amz_target("NoDotHere").is_none());
1247 assert!(parse_amz_target("").is_none());
1248 }
1249
1250 #[test]
1251 fn parse_amz_target_cloudwatch_json() {
1252 let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
1254 assert_eq!(result.service, "monitoring");
1255 assert_eq!(result.action, "PutMetricData");
1256 assert_eq!(result.protocol, AwsProtocol::Json);
1257 }
1258
1259 #[test]
1260 fn flatten_json_to_query_nested() {
1261 let body = Bytes::from(
1262 serde_json::json!({
1263 "Namespace": "MyApp",
1264 "MetricData": [{
1265 "MetricName": "Latency",
1266 "Value": 12.5,
1267 "StatisticValues": {"SampleCount": 3, "Sum": 10},
1268 "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
1269 }]
1270 })
1271 .to_string(),
1272 );
1273 let flat = flatten_json_to_query(&body);
1274 assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
1275 assert_eq!(
1276 flat.get("MetricData.member.1.MetricName").unwrap(),
1277 "Latency"
1278 );
1279 assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
1280 assert_eq!(
1281 flat.get("MetricData.member.1.StatisticValues.SampleCount")
1282 .unwrap(),
1283 "3"
1284 );
1285 assert_eq!(
1286 flat.get("MetricData.member.1.Dimensions.member.1.Name")
1287 .unwrap(),
1288 "Endpoint"
1289 );
1290 assert_eq!(
1291 flat.get("MetricData.member.1.Dimensions.member.1.Value")
1292 .unwrap(),
1293 "/api"
1294 );
1295 }
1296
1297 #[test]
1298 fn flatten_json_to_query_non_object_is_empty() {
1299 assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
1300 assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
1301 }
1302
1303 #[test]
1304 fn parse_amz_target_various_prefixes() {
1305 assert_eq!(
1306 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
1307 "sqs"
1308 );
1309 assert_eq!(
1310 parse_amz_target("AmazonSNS.Publish").unwrap().service,
1311 "sns"
1312 );
1313 assert_eq!(
1314 parse_amz_target("DynamoDB_20120810.GetItem")
1315 .unwrap()
1316 .service,
1317 "dynamodb"
1318 );
1319 assert_eq!(
1320 parse_amz_target("Logs_20140328.PutLogEvents")
1321 .unwrap()
1322 .service,
1323 "logs"
1324 );
1325 assert_eq!(
1326 parse_amz_target("secretsmanager.GetSecretValue")
1327 .unwrap()
1328 .service,
1329 "secretsmanager"
1330 );
1331 assert_eq!(
1332 parse_amz_target("TrentService.Encrypt").unwrap().service,
1333 "kms"
1334 );
1335 assert_eq!(
1336 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
1337 .unwrap()
1338 .service,
1339 "cognito-idp"
1340 );
1341 assert_eq!(
1342 parse_amz_target("AWSStepFunctions.StartExecution")
1343 .unwrap()
1344 .service,
1345 "states"
1346 );
1347 assert_eq!(
1348 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
1349 .unwrap()
1350 .service,
1351 "organizations"
1352 );
1353 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
1354 }
1355
1356 #[test]
1357 fn infer_service_from_action_maps_sts() {
1358 assert_eq!(
1359 infer_service_from_action("AssumeRole").as_deref(),
1360 Some("sts")
1361 );
1362 assert_eq!(
1363 infer_service_from_action("GetCallerIdentity").as_deref(),
1364 Some("sts")
1365 );
1366 }
1367
1368 #[test]
1369 fn infer_service_from_action_maps_iam() {
1370 assert_eq!(
1371 infer_service_from_action("CreateUser").as_deref(),
1372 Some("iam")
1373 );
1374 assert_eq!(
1375 infer_service_from_action("ListRoles").as_deref(),
1376 Some("iam")
1377 );
1378 }
1379
1380 #[test]
1381 fn infer_service_from_action_maps_ses() {
1382 assert_eq!(
1383 infer_service_from_action("SendEmail").as_deref(),
1384 Some("ses")
1385 );
1386 assert_eq!(
1387 infer_service_from_action("ListIdentities").as_deref(),
1388 Some("ses")
1389 );
1390 }
1391
1392 #[test]
1393 fn infer_service_from_action_maps_sns_confirmation_flow() {
1394 assert_eq!(
1397 infer_service_from_action("ConfirmSubscription").as_deref(),
1398 Some("sns")
1399 );
1400 assert_eq!(
1401 infer_service_from_action("Unsubscribe").as_deref(),
1402 Some("sns")
1403 );
1404 }
1405
1406 #[test]
1407 fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1408 let mut headers = HeaderMap::new();
1411 headers.insert("host", "localhost:4566".parse().unwrap());
1412 let mut query_params = HashMap::new();
1413 query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1414 query_params.insert(
1415 "TopicArn".to_string(),
1416 "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1417 );
1418 query_params.insert("Token".to_string(), "abc123".to_string());
1419
1420 let detected = detect_service(&headers, &query_params, &Bytes::new())
1421 .expect("ConfirmSubscription must route to a service");
1422 assert_eq!(detected.service, "sns");
1423 assert_eq!(detected.action, "ConfirmSubscription");
1424 assert_eq!(detected.protocol, AwsProtocol::Query);
1425 }
1426
1427 #[test]
1428 fn infer_service_from_action_unknown_returns_none() {
1429 assert!(infer_service_from_action("NotARealAction").is_none());
1430 }
1431
1432 #[test]
1433 fn rest_protocol_for_returns_none_for_non_rest_service() {
1434 assert!(rest_protocol_for("sqs").is_none());
1435 }
1436
1437 #[test]
1438 fn url_decode_handles_percent_and_plus() {
1439 assert_eq!(url_decode("hello+world"), "hello world");
1440 assert_eq!(url_decode("hello%20world"), "hello world");
1441 assert_eq!(url_decode("100%25"), "100%");
1442 }
1443
1444 #[test]
1445 fn url_decode_ignores_malformed_percent() {
1446 assert_eq!(url_decode("%ZZ"), "");
1447 }
1448
1449 #[test]
1450 fn from_hex_valid_digits() {
1451 assert_eq!(from_hex(b'0'), Some(0));
1452 assert_eq!(from_hex(b'9'), Some(9));
1453 assert_eq!(from_hex(b'a'), Some(10));
1454 assert_eq!(from_hex(b'F'), Some(15));
1455 }
1456
1457 #[test]
1458 fn from_hex_invalid_returns_none() {
1459 assert!(from_hex(b'g').is_none());
1460 assert!(from_hex(b' ').is_none());
1461 }
1462
1463 #[test]
1464 fn detect_service_via_amz_target() {
1465 let mut headers = HeaderMap::new();
1466 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1467 let query = HashMap::new();
1468 let body = Bytes::new();
1469 let detected = detect_service(&headers, &query, &body).unwrap();
1470 assert_eq!(detected.service, "ssm");
1471 assert_eq!(detected.action, "GetParameter");
1472 }
1473
1474 #[test]
1475 fn detect_service_via_query_action_with_inferred_service() {
1476 let headers = HeaderMap::new();
1477 let mut query = HashMap::new();
1478 query.insert("Action".to_string(), "AssumeRole".to_string());
1479 let body = Bytes::new();
1480 let detected = detect_service(&headers, &query, &body).unwrap();
1481 assert_eq!(detected.service, "sts");
1482 assert_eq!(detected.action, "AssumeRole");
1483 assert_eq!(detected.protocol, AwsProtocol::Query);
1484 }
1485
1486 #[test]
1487 fn detect_service_via_form_body() {
1488 let headers = HeaderMap::new();
1489 let query = HashMap::new();
1490 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1491 let detected = detect_service(&headers, &query, &body).unwrap();
1492 assert_eq!(detected.service, "ses");
1493 assert_eq!(detected.action, "SendEmail");
1494 }
1495
1496 #[test]
1497 fn detect_service_via_sigv2_presigned() {
1498 let headers = HeaderMap::new();
1499 let mut query = HashMap::new();
1500 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1501 query.insert("Signature".to_string(), "sig".to_string());
1502 query.insert("Expires".to_string(), "1234567890".to_string());
1503 let body = Bytes::new();
1504 let detected = detect_service(&headers, &query, &body).unwrap();
1505 assert_eq!(detected.service, "s3");
1506 assert_eq!(detected.protocol, AwsProtocol::Rest);
1507 }
1508
1509 #[test]
1510 fn detect_service_via_sigv4_presigned_credential() {
1511 let headers = HeaderMap::new();
1512 let mut query = HashMap::new();
1513 query.insert(
1514 "X-Amz-Credential".to_string(),
1515 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1516 );
1517 let body = Bytes::new();
1518 let detected = detect_service(&headers, &query, &body).unwrap();
1519 assert_eq!(detected.service, "s3");
1520 assert_eq!(detected.protocol, AwsProtocol::Rest);
1521 }
1522
1523 #[test]
1524 fn detect_service_unknown_returns_none() {
1525 let headers = HeaderMap::new();
1526 let query = HashMap::new();
1527 let body = Bytes::new();
1528 assert!(detect_service(&headers, &query, &body).is_none());
1529 }
1530
1531 #[test]
1532 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1533 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1538 }
1539
1540 #[test]
1541 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1542 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1547 }
1548
1549 #[test]
1550 fn normalize_service_name_passes_through_unaliased_services() {
1551 assert_eq!(normalize_service_name("bedrock"), "bedrock");
1555 assert_eq!(normalize_service_name("s3"), "s3");
1556 assert_eq!(normalize_service_name("lambda"), "lambda");
1557 assert_eq!(normalize_service_name(""), "");
1558 assert_eq!(
1559 normalize_service_name("unknown-future-service"),
1560 "unknown-future-service"
1561 );
1562 }
1563
1564 #[test]
1565 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1566 let mut headers = HeaderMap::new();
1571 headers.insert(
1572 "authorization",
1573 "AWS4-HMAC-SHA256 \
1574 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1575 SignedHeaders=host, Signature=abc"
1576 .parse()
1577 .unwrap(),
1578 );
1579 let query = HashMap::new();
1580 let body = Bytes::new();
1581 let detected = detect_service(&headers, &query, &body).unwrap();
1582 assert_eq!(detected.service, "bedrock");
1583 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1584 }
1585
1586 #[test]
1587 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1588 let headers = HeaderMap::new();
1592 let mut query = HashMap::new();
1593 query.insert(
1594 "X-Amz-Credential".to_string(),
1595 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1596 );
1597 let body = Bytes::new();
1598 let detected = detect_service(&headers, &query, &body).unwrap();
1599 assert_eq!(detected.service, "bedrock");
1600 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1601 }
1602
1603 #[test]
1604 fn normalize_host_strips_port_and_case() {
1605 assert_eq!(normalize_host("Rest.Example.com:4566"), "rest.example.com");
1606 assert_eq!(normalize_host("rest.example.com."), "rest.example.com");
1607 assert_eq!(normalize_host("rest.example.com"), "rest.example.com");
1608 assert_eq!(normalize_host("[::1]:4566"), "[::1]");
1609 assert_eq!(normalize_host("localhost"), "localhost");
1610 }
1611
1612 #[test]
1613 fn parse_routing_host_execute_api_hosts() {
1614 assert_eq!(
1617 parse_routing_host("abc123.execute-api.localhost.localstack.cloud:4566"),
1618 None
1619 );
1620 let h =
1621 parse_routing_host("abc123.execute-api.eu-west-1.localhost.localstack.cloud").unwrap();
1622 assert_eq!(h.service, "execute-api");
1623 assert_eq!(h.region, "eu-west-1");
1624 let h = parse_routing_host("abc123.execute-api.us-west-2.amazonaws.com").unwrap();
1625 assert_eq!(h.service, "execute-api");
1626 assert_eq!(h.region, "us-west-2");
1627 assert_eq!(h.bucket, None);
1628 }
1629
1630 #[test]
1631 fn detect_rpc_v2_cbor_requests() {
1632 let mut headers = HeaderMap::new();
1633 let path = "/service/GraniteServiceVersion20100801/operation/GetMetricData";
1634 assert!(detect_rpc_v2_cbor(&headers, path).is_none());
1636 headers.insert("smithy-protocol", "rpc-v2-cbor".parse().unwrap());
1637 let d = detect_rpc_v2_cbor(&headers, path).unwrap();
1638 assert_eq!(d.service, "monitoring");
1639 assert_eq!(d.action, "GetMetricData");
1640 assert_eq!(d.protocol, AwsProtocol::RpcV2Cbor);
1641 assert!(detect_rpc_v2_cbor(&headers, "/service/Unknown/operation/Op").is_none());
1642 assert!(detect_rpc_v2_cbor(&headers, "/prod/items").is_none());
1643 }
1644
1645 #[test]
1646 fn parse_routing_host_localstack_basic() {
1647 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1648 assert_eq!(h.service, "sqs");
1649 assert_eq!(h.region, "us-east-1");
1650 assert!(h.bucket.is_none());
1651 }
1652
1653 #[test]
1654 fn parse_routing_host_localstack_with_port() {
1655 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1656 assert_eq!(h.service, "lambda");
1657 assert_eq!(h.region, "eu-west-1");
1658 assert!(h.bucket.is_none());
1659 }
1660
1661 #[test]
1662 fn parse_routing_host_case_insensitive() {
1663 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1664 assert_eq!(h.service, "sqs");
1665 assert_eq!(h.region, "us-east-1");
1666
1667 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1668 assert_eq!(h.service, "lambda");
1669 assert_eq!(h.region, "us-east-1");
1670 }
1671
1672 #[test]
1673 fn parse_routing_host_localstack_s3_virtual_hosted() {
1674 let h =
1675 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1676 assert_eq!(h.service, "s3");
1677 assert_eq!(h.region, "us-east-1");
1678 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1679 }
1680
1681 #[test]
1682 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1683 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1684 assert_eq!(h.service, "s3");
1685 assert_eq!(h.region, "us-east-1");
1686 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1687 }
1688
1689 #[test]
1690 fn parse_routing_host_aws_service_region() {
1691 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1692 assert_eq!(h.service, "sqs");
1693 assert_eq!(h.region, "us-east-1");
1694 assert!(h.bucket.is_none());
1695
1696 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1697 assert_eq!(h.service, "dynamodb");
1698 assert_eq!(h.region, "eu-west-2");
1699 }
1700
1701 #[test]
1702 fn parse_routing_host_aws_s3_path_style_modern() {
1703 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1704 assert_eq!(h.service, "s3");
1705 assert_eq!(h.region, "us-east-1");
1706 assert!(h.bucket.is_none());
1707 }
1708
1709 #[test]
1710 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1711 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1712 assert_eq!(h.service, "s3");
1713 assert_eq!(h.region, "us-east-1");
1714 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1715 }
1716
1717 #[test]
1718 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1719 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1720 assert_eq!(h.service, "s3");
1721 assert_eq!(h.region, "us-east-1");
1722 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1723 }
1724
1725 #[test]
1726 fn parse_routing_host_aws_s3_legacy_global() {
1727 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1730 assert_eq!(h.service, "s3");
1731 assert_eq!(h.region, "us-east-1");
1732 assert!(h.bucket.is_none());
1733
1734 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1735 assert_eq!(h.service, "s3");
1736 assert_eq!(h.region, "us-east-1");
1737 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1738 }
1739
1740 #[test]
1741 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1742 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1745 assert_eq!(h.service, "s3");
1746 assert_eq!(h.region, "us-east-1");
1747 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1748 }
1749
1750 #[test]
1751 fn parse_routing_host_aws_s3_dash_separated() {
1752 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1754 assert_eq!(h.service, "s3");
1755 assert_eq!(h.region, "us-west-2");
1756 assert!(h.bucket.is_none());
1757
1758 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1759 assert_eq!(h.service, "s3");
1760 assert_eq!(h.region, "us-west-2");
1761 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1762 }
1763
1764 #[test]
1765 fn parse_routing_host_aws_s3_dualstack_and_fips() {
1766 for (host, bucket) in [
1767 (
1768 "my-bucket.s3.dualstack.us-east-1.amazonaws.com",
1769 Some("my-bucket"),
1770 ),
1771 ("a.b.s3.dualstack.eu-west-2.amazonaws.com", Some("a.b")),
1772 ("s3.dualstack.eu-west-2.amazonaws.com", None),
1773 (
1774 "my-bucket.s3-fips.us-gov-west-1.amazonaws.com",
1775 Some("my-bucket"),
1776 ),
1777 (
1778 "my-bucket.s3-fips.dualstack.us-east-1.amazonaws.com",
1779 Some("my-bucket"),
1780 ),
1781 ] {
1782 let h = parse_routing_host(host).unwrap();
1783 assert_eq!(h.service, "s3", "{host}");
1784 assert_eq!(h.bucket.as_deref(), bucket, "{host}");
1785 }
1786 let h = parse_routing_host("b.s3.dualstack.eu-west-2.amazonaws.com").unwrap();
1787 assert_eq!(h.region, "eu-west-2");
1788 }
1789
1790 #[test]
1791 fn parse_routing_host_aws_s3_website_endpoints() {
1792 let h = parse_routing_host("site.s3-website-us-west-2.amazonaws.com").unwrap();
1795 assert_eq!(h.service, "s3");
1796 assert_eq!(h.region, "us-west-2");
1797 assert_eq!(h.bucket.as_deref(), Some("site"));
1798
1799 let h = parse_routing_host("my.site.s3-website.eu-central-1.amazonaws.com").unwrap();
1801 assert_eq!(h.service, "s3");
1802 assert_eq!(h.region, "eu-central-1");
1803 assert_eq!(h.bucket.as_deref(), Some("my.site"));
1804 }
1805
1806 #[test]
1807 fn parse_routing_host_other_partition_suffixes() {
1808 let h = parse_routing_host("my-bucket.s3.cn-north-1.amazonaws.com.cn").unwrap();
1809 assert_eq!(h.service, "s3");
1810 assert_eq!(h.region, "cn-north-1");
1811 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1812
1813 let h = parse_routing_host("sqs.cn-northwest-1.amazonaws.com.cn").unwrap();
1814 assert_eq!(h.service, "sqs");
1815 assert_eq!(h.region, "cn-northwest-1");
1816
1817 let h = parse_routing_host("b.s3.us-iso-east-1.c2s.ic.gov").unwrap();
1818 assert_eq!(h.region, "us-iso-east-1");
1819 assert_eq!(h.bucket.as_deref(), Some("b"));
1820
1821 assert!(parse_routing_host("amazonaws.com.cn").is_none());
1822 assert!(parse_routing_host(".amazonaws.com.cn").is_none());
1823 }
1824
1825 #[test]
1826 fn parse_routing_host_rejects_plain_localhost() {
1827 assert!(parse_routing_host("localhost:4566").is_none());
1828 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1829 }
1830
1831 #[test]
1832 fn parse_routing_host_rejects_unknown_suffix() {
1833 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1834 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1835 }
1836
1837 #[test]
1838 fn parse_routing_host_empty_and_malformed_rejected() {
1839 assert!(parse_routing_host("").is_none());
1840 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1841 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1842 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1843 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1844 assert!(parse_routing_host(".amazonaws.com").is_none());
1845 assert!(parse_routing_host("amazonaws.com").is_none());
1846 }
1847
1848 #[test]
1849 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1850 assert!(parse_routing_host("s3-accesspoint").is_none());
1854 }
1855
1856 #[test]
1857 fn detect_service_via_host_for_rest_service() {
1858 let mut headers = HeaderMap::new();
1859 headers.insert(
1860 "host",
1861 "s3.us-east-1.localhost.localstack.cloud:4566"
1862 .parse()
1863 .unwrap(),
1864 );
1865 let query = HashMap::new();
1866 let body = Bytes::new();
1867 let detected = detect_service(&headers, &query, &body).unwrap();
1868 assert_eq!(detected.service, "s3");
1869 assert_eq!(detected.protocol, AwsProtocol::Rest);
1870 }
1871
1872 #[test]
1873 fn detect_service_via_host_for_rest_json_service() {
1874 let mut headers = HeaderMap::new();
1875 headers.insert(
1876 "host",
1877 "lambda.us-east-1.localhost.localstack.cloud:4566"
1878 .parse()
1879 .unwrap(),
1880 );
1881 let query = HashMap::new();
1882 let body = Bytes::new();
1883 let detected = detect_service(&headers, &query, &body).unwrap();
1884 assert_eq!(detected.service, "lambda");
1885 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1886 }
1887
1888 #[test]
1889 fn detect_service_via_host_plus_query_action() {
1890 let mut headers = HeaderMap::new();
1891 headers.insert(
1892 "host",
1893 "sqs.us-east-1.localhost.localstack.cloud:4566"
1894 .parse()
1895 .unwrap(),
1896 );
1897 let mut query = HashMap::new();
1898 query.insert("Action".to_string(), "ListQueues".to_string());
1899 let body = Bytes::new();
1900 let detected = detect_service(&headers, &query, &body).unwrap();
1901 assert_eq!(detected.service, "sqs");
1902 assert_eq!(detected.action, "ListQueues");
1903 assert_eq!(detected.protocol, AwsProtocol::Query);
1904 }
1905
1906 #[test]
1907 fn detect_service_sigv4_wins_over_host() {
1908 let mut headers = HeaderMap::new();
1909 headers.insert(
1910 "authorization",
1911 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1912 SignedHeaders=host, Signature=abc"
1913 .parse()
1914 .unwrap(),
1915 );
1916 headers.insert(
1917 "host",
1918 "lambda.us-east-1.localhost.localstack.cloud:4566"
1919 .parse()
1920 .unwrap(),
1921 );
1922 let query = HashMap::new();
1923 let body = Bytes::new();
1924 let detected = detect_service(&headers, &query, &body).unwrap();
1925 assert_eq!(detected.service, "s3");
1927 assert_eq!(detected.protocol, AwsProtocol::Rest);
1928 }
1929
1930 #[test]
1931 fn detect_service_host_for_virtual_hosted_s3() {
1932 let mut headers = HeaderMap::new();
1933 headers.insert(
1934 "host",
1935 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1936 .parse()
1937 .unwrap(),
1938 );
1939 let query = HashMap::new();
1940 let body = Bytes::new();
1941 let detected = detect_service(&headers, &query, &body).unwrap();
1942 assert_eq!(detected.service, "s3");
1943 assert_eq!(detected.protocol, AwsProtocol::Rest);
1944 }
1945}