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}
31
32fn query_protocol_for(service: &str) -> AwsProtocol {
36 if service == "ec2" {
37 AwsProtocol::Ec2Query
38 } else {
39 AwsProtocol::Query
40 }
41}
42
43const REST_XML_SERVICES: &[&str] = &["s3", "cloudfront", "route53"];
45
46const REST_JSON_SERVICES: &[&str] = &[
48 "managedblockchain",
49 "lambda",
50 "ses",
51 "apigateway",
52 "bedrock",
53 "bedrock-agent",
54 "bedrock-agent-runtime",
55 "scheduler",
56 "batch",
57 "pipes",
58 "rds-data",
59 "dsql",
60 "resource-groups",
61 "eks",
62 "glacier",
63 "backup",
64 "ram",
67 "s3tables",
70 "lakeformation",
73 "es",
77 "account",
78 "appconfig",
82 "codeartifact",
85 "elasticfilesystem",
89 "mq",
92 "kafka",
96 "mwaa",
100 "fis",
105 "xray",
110 "appsync",
115 "amplify",
120 "mediaconvert",
125 "serverlessrepo",
130 "iotdata",
135 "pinpoint",
141 "iot",
146 "iotwireless",
151];
152
153#[derive(Debug, Clone)]
155pub struct DetectedRequest {
156 pub service: String,
157 pub action: String,
158 pub protocol: AwsProtocol,
159}
160
161pub fn detect_service_headers_only(
168 headers: &HeaderMap,
169 query_params: &HashMap<String, String>,
170) -> Option<DetectedRequest> {
171 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
173 return parse_amz_target(target);
174 }
175 if let Some(action) = query_params.get("Action") {
176 let service = extract_service_from_auth(headers)
177 .or_else(|| infer_service_from_action(action))
178 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
179 if let Some(service) = service {
180 let protocol = query_protocol_for(&service);
181 return Some(DetectedRequest {
182 service,
183 action: action.clone(),
184 protocol,
185 });
186 }
187 }
188 if let Some(service) = extract_service_from_auth(headers) {
189 if let Some(protocol) = rest_protocol_for(&service) {
190 return Some(DetectedRequest {
191 service,
192 action: String::new(),
193 protocol,
194 });
195 }
196 }
197 if let Some(credential) = query_params.get("X-Amz-Credential") {
198 let parts: Vec<&str> = credential.split('/').collect();
199 if parts.len() >= 4 {
200 let service = normalize_service_name(parts[3]).to_string();
201 if let Some(protocol) = rest_protocol_for(&service) {
202 return Some(DetectedRequest {
203 service,
204 action: String::new(),
205 protocol,
206 });
207 }
208 }
209 }
210 if query_params.contains_key("AWSAccessKeyId")
211 && query_params.contains_key("Signature")
212 && query_params.contains_key("Expires")
213 {
214 return Some(DetectedRequest {
215 service: "s3".to_string(),
216 action: String::new(),
217 protocol: AwsProtocol::Rest,
218 });
219 }
220 if let Some(host_info) = parse_routing_host_from_headers(headers) {
221 if let Some(protocol) = rest_protocol_for(&host_info.service) {
222 return Some(DetectedRequest {
223 service: host_info.service,
224 action: String::new(),
225 protocol,
226 });
227 }
228 }
229 None
230}
231
232pub fn detect_service(
234 headers: &HeaderMap,
235 query_params: &HashMap<String, String>,
236 body: &Bytes,
237) -> Option<DetectedRequest> {
238 if let Some(target) = headers.get("x-amz-target").and_then(|v| v.to_str().ok()) {
240 return parse_amz_target(target);
241 }
242
243 if let Some(action) = query_params.get("Action") {
245 let service = extract_service_from_auth(headers)
246 .or_else(|| infer_service_from_action(action))
247 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
248 if let Some(service) = service {
249 let protocol = query_protocol_for(&service);
250 return Some(DetectedRequest {
251 service,
252 action: action.clone(),
253 protocol,
254 });
255 }
256 }
257
258 {
260 let form_params = decode_form_urlencoded(body);
261
262 if let Some(action) = form_params.get("Action") {
263 let service = extract_service_from_auth(headers)
264 .or_else(|| infer_service_from_action(action))
265 .or_else(|| parse_routing_host_from_headers(headers).map(|h| h.service));
266 if let Some(service) = service {
267 let protocol = query_protocol_for(&service);
268 return Some(DetectedRequest {
269 service,
270 action: action.clone(),
271 protocol,
272 });
273 }
274 }
275 }
276
277 if let Some(service) = extract_service_from_auth(headers) {
279 if let Some(protocol) = rest_protocol_for(&service) {
280 return Some(DetectedRequest {
281 service,
282 action: String::new(), protocol,
284 });
285 }
286 }
287
288 if let Some(credential) = query_params.get("X-Amz-Credential") {
290 let parts: Vec<&str> = credential.split('/').collect();
292 if parts.len() >= 4 {
293 let service = normalize_service_name(parts[3]).to_string();
294 if let Some(protocol) = rest_protocol_for(&service) {
295 return Some(DetectedRequest {
296 service,
297 action: String::new(),
298 protocol,
299 });
300 }
301 }
302 }
303
304 if query_params.contains_key("AWSAccessKeyId")
308 && query_params.contains_key("Signature")
309 && query_params.contains_key("Expires")
310 {
311 return Some(DetectedRequest {
312 service: "s3".to_string(),
313 action: String::new(),
314 protocol: AwsProtocol::Rest,
315 });
316 }
317
318 if let Some(host_info) = parse_routing_host_from_headers(headers) {
322 if let Some(protocol) = rest_protocol_for(&host_info.service) {
323 return Some(DetectedRequest {
324 service: host_info.service,
325 action: String::new(),
326 protocol,
327 });
328 }
329 }
330
331 None
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct RoutingHost {
344 pub service: String,
345 pub region: String,
346 pub bucket: Option<String>,
348}
349
350const LOCALSTACK_SUFFIX: &str = ".localhost.localstack.cloud";
351const AWS_SUFFIX: &str = ".amazonaws.com";
352
353pub fn parse_routing_host(host: &str) -> Option<RoutingHost> {
357 let hostname = host.split(':').next()?;
358 if hostname.is_empty() {
359 return None;
360 }
361 let hostname = hostname.to_ascii_lowercase();
362 if let Some(prefix) = hostname.strip_suffix(LOCALSTACK_SUFFIX) {
363 return parse_localstack_prefix(prefix);
364 }
365 if hostname == "amazonaws.com" {
366 return None;
367 }
368 if let Some(prefix) = hostname.strip_suffix(AWS_SUFFIX) {
369 return parse_aws_prefix(prefix);
370 }
371 None
372}
373
374pub fn parse_routing_host_from_headers(headers: &HeaderMap) -> Option<RoutingHost> {
376 let host = headers.get("host")?.to_str().ok()?;
377 parse_routing_host(host)
378}
379
380fn parse_localstack_prefix(prefix: &str) -> Option<RoutingHost> {
381 if prefix.is_empty() {
382 return None;
383 }
384 let labels: Vec<&str> = prefix.split('.').collect();
385 if labels.iter().any(|l| l.is_empty()) {
386 return None;
387 }
388 match labels.len() {
389 2 => Some(RoutingHost {
390 service: labels[0].to_string(),
391 region: labels[1].to_string(),
392 bucket: None,
393 }),
394 n if n >= 3 && labels[n - 2] == "s3" => {
395 let bucket = labels[..n - 2].join(".");
396 Some(RoutingHost {
397 service: "s3".to_string(),
398 region: labels[n - 1].to_string(),
399 bucket: Some(bucket),
400 })
401 }
402 n if n >= 3 && labels[n - 2] == "s3-accesspoint" => {
403 let bucket = labels[..n - 2].join(".");
404 Some(RoutingHost {
405 service: "s3".to_string(),
406 region: labels[n - 1].to_string(),
407 bucket: Some(bucket),
408 })
409 }
410 n if n >= 3 && labels[n - 2] == "s3-control" => Some(RoutingHost {
411 service: "s3".to_string(),
412 region: labels[n - 1].to_string(),
413 bucket: None,
414 }),
415 _ => None,
416 }
417}
418
419fn parse_aws_prefix(prefix: &str) -> Option<RoutingHost> {
431 if prefix.is_empty() {
432 return None;
433 }
434 let labels: Vec<&str> = prefix.split('.').collect();
435 if labels.iter().any(|l| l.is_empty()) {
436 return None;
437 }
438 let last = *labels.last()?;
439
440 if let Some(region) = last.strip_prefix("s3-") {
443 if !region.is_empty() {
444 let bucket = if labels.len() >= 2 {
445 Some(labels[..labels.len() - 1].join("."))
446 } else {
447 None
448 };
449 return Some(RoutingHost {
450 service: "s3".to_string(),
451 region: region.to_string(),
452 bucket,
453 });
454 }
455 }
456
457 if last == "s3" {
461 if labels.len() == 1 {
462 return Some(RoutingHost {
463 service: "s3".to_string(),
464 region: "us-east-1".to_string(),
465 bucket: None,
466 });
467 }
468 return Some(RoutingHost {
469 service: "s3".to_string(),
470 region: "us-east-1".to_string(),
471 bucket: Some(labels[..labels.len() - 1].join(".")),
472 });
473 }
474
475 if last == "s3-accesspoint" {
478 if labels.len() == 2 {
479 return Some(RoutingHost {
480 service: "s3".to_string(),
481 region: labels[0].to_string(),
482 bucket: None,
483 });
484 }
485 if labels.len() >= 3 {
489 let bucket = labels[..labels.len() - 2].join(".");
490 return Some(RoutingHost {
491 service: "s3".to_string(),
492 region: labels[labels.len() - 1].to_string(),
493 bucket: Some(bucket),
494 });
495 }
496 }
497
498 if labels.len() >= 2 && labels[labels.len() - 2] == "s3-control" {
501 return Some(RoutingHost {
502 service: "s3".to_string(),
503 region: last.to_string(),
504 bucket: None,
505 });
506 }
507
508 match labels.len() {
509 2 => Some(RoutingHost {
512 service: labels[0].to_string(),
513 region: labels[1].to_string(),
514 bucket: None,
515 }),
516 n if n >= 3 && labels[n - 2] == "s3" => {
518 let bucket = labels[..n - 2].join(".");
519 Some(RoutingHost {
520 service: "s3".to_string(),
521 region: labels[n - 1].to_string(),
522 bucket: Some(bucket),
523 })
524 }
525 _ => None,
526 }
527}
528
529fn parse_amz_target(target: &str) -> Option<DetectedRequest> {
532 let (prefix, action) = target.rsplit_once('.')?;
533
534 let service = match prefix {
535 "AWSEvents" => "events",
536 "AmazonSSM" => "ssm",
537 "AmazonSQS" => "sqs",
538 "AmazonSNS" => "sns",
539 "DynamoDB_20120810" => "dynamodb",
540 "DynamoDBStreams_20120810" => "dynamodbstreams",
541 "Logs_20140328" => "logs",
542 s if s.starts_with("secretsmanager") => "secretsmanager",
543 s if s.starts_with("TrentService") => "kms",
544 s if s.starts_with("AWSCognitoIdentityProviderService") => "cognito-idp",
545 s if s.starts_with("AWSCognitoIdentityService") => "cognito-identity",
546 s if s.starts_with("Kinesis_20131202") => "kinesis",
547 s if s.starts_with("AmazonEC2ContainerRegistry_V") => "ecr",
548 s if s.starts_with("AmazonEC2ContainerServiceV") => "ecs",
549 s if s.starts_with("AWSStepFunctions") => "states",
550 s if s.starts_with("AWSOrganizationsV") => "organizations",
551 "CertificateManager" => "acm",
552 "ACMPrivateCA" => "acm-pca",
553 "Route53Resolver" => "route53resolver",
557 "StarlingDoveService" => "config",
560 "AnyScaleFrontendService" => "application-autoscaling",
561 "AWSWAF_20190729" => "wafv2",
564 "AmazonAthena" => "athena",
565 s if s.starts_with("Firehose_") => "firehose",
566 "AWSGlue" => "glue",
567 "ElasticMapReduce" => "emr",
570 "Textract" => "textract",
573 "Transcribe" => "transcribe",
576 "AWSShineFrontendService_20170701" => "translate",
579 "AWSShield_20160616" => "shield",
582 "Comprehend_20171127" => "comprehend",
585 "SimpleWorkflowService" => "swf",
588 "Timestream_20181101" => "timestream",
592 "AWSSupport_20130415" => "support",
595 "CloudApiService" => "cloudcontrolapi",
596 "ResourceGroupsTaggingAPI_20170126" => "tagging",
597 "AmazonMemoryDB" => "memorydb",
598 s if s.starts_with("KinesisAnalytics_20180523") => "kinesisanalyticsv2",
603 "Route53AutoNaming_v20170314" => "servicediscovery",
606 "AmazonDMSv20160101" => "dms",
608 "CloudTrail_20131101" => "cloudtrail",
612 "com.amazonaws.cloudtrail.v20131101.CloudTrail_20131101" => "cloudtrail",
613 "AWSInsightsIndexService" => "ce",
617 "com.amazonaws.costexplorer.v20171025.AWSInsightsIndexService" => "ce",
618 "TransferService" => "transfer",
620 "CodeBuild_20161006" => "codebuild",
622 "CodeCommit_20150413" => "codecommit",
624 "AWSIdentityStore" => "identitystore",
626 "SWBExternalService" => "sso",
628 "VerifiedPermissions" => "verifiedpermissions",
630 "CodeConnections_20231201" => "codeconnections",
632 "CodeStar_connections_20191201" => "codeconnections",
638 "CodeDeploy_20141006" => "codedeploy",
640 "CodePipeline_20150709" => "codepipeline",
642 s if s.starts_with("GraniteServiceVersion") => "monitoring",
648 "SageMaker" => "sagemaker",
651 _ => return None,
652 };
653
654 Some(DetectedRequest {
655 service: service.to_string(),
656 action: action.to_string(),
657 protocol: AwsProtocol::Json,
658 })
659}
660
661fn rest_protocol_for(service: &str) -> Option<AwsProtocol> {
663 if REST_XML_SERVICES.contains(&service) {
664 Some(AwsProtocol::Rest)
665 } else if REST_JSON_SERVICES.contains(&service) {
666 Some(AwsProtocol::RestJson)
667 } else {
668 None
669 }
670}
671
672fn infer_service_from_action(action: &str) -> Option<String> {
676 match action {
677 "AssumeRole"
678 | "AssumeRoleWithSAML"
679 | "AssumeRoleWithWebIdentity"
680 | "GetCallerIdentity"
681 | "GetSessionToken"
682 | "GetFederationToken"
683 | "GetAccessKeyInfo"
684 | "DecodeAuthorizationMessage" => Some("sts".to_string()),
685 "CreateUser" | "DeleteUser" | "GetUser" | "ListUsers" | "CreateRole" | "DeleteRole"
686 | "GetRole" | "ListRoles" | "CreatePolicy" | "DeletePolicy" | "GetPolicy"
687 | "ListPolicies" | "AttachRolePolicy" | "DetachRolePolicy" | "CreateAccessKey"
688 | "DeleteAccessKey" | "ListAccessKeys" | "ListRolePolicies" => Some("iam".to_string()),
689 "VerifyEmailIdentity"
691 | "VerifyDomainIdentity"
692 | "VerifyDomainDkim"
693 | "ListIdentities"
694 | "GetIdentityVerificationAttributes"
695 | "GetIdentityDkimAttributes"
696 | "DeleteIdentity"
697 | "SetIdentityDkimEnabled"
698 | "SetIdentityNotificationTopic"
699 | "SetIdentityFeedbackForwardingEnabled"
700 | "GetIdentityNotificationAttributes"
701 | "GetIdentityMailFromDomainAttributes"
702 | "SetIdentityMailFromDomain"
703 | "SendEmail"
704 | "SendRawEmail"
705 | "SendTemplatedEmail"
706 | "SendBulkTemplatedEmail"
707 | "CreateTemplate"
708 | "GetTemplate"
709 | "ListTemplates"
710 | "DeleteTemplate"
711 | "UpdateTemplate"
712 | "CreateConfigurationSet"
713 | "DeleteConfigurationSet"
714 | "DescribeConfigurationSet"
715 | "ListConfigurationSets"
716 | "CreateConfigurationSetEventDestination"
717 | "UpdateConfigurationSetEventDestination"
718 | "DeleteConfigurationSetEventDestination"
719 | "GetSendQuota"
720 | "GetSendStatistics"
721 | "GetAccountSendingEnabled"
722 | "CreateReceiptRuleSet"
723 | "DeleteReceiptRuleSet"
724 | "DescribeReceiptRuleSet"
725 | "ListReceiptRuleSets"
726 | "CloneReceiptRuleSet"
727 | "SetActiveReceiptRuleSet"
728 | "ReorderReceiptRuleSet"
729 | "CreateReceiptRule"
730 | "DeleteReceiptRule"
731 | "DescribeReceiptRule"
732 | "UpdateReceiptRule"
733 | "CreateReceiptFilter"
734 | "DeleteReceiptFilter"
735 | "ListReceiptFilters" => Some("ses".to_string()),
736 "ConfirmSubscription" | "Unsubscribe" => Some("sns".to_string()),
740 _ => None,
741 }
742}
743
744fn extract_service_from_auth(headers: &HeaderMap) -> Option<String> {
746 let auth = headers.get("authorization")?.to_str().ok()?;
747 let info = fakecloud_aws::sigv4::parse_sigv4(auth)?;
748 Some(normalize_service_name(&info.service).to_string())
749}
750
751fn normalize_service_name(service: &str) -> &str {
763 match service {
764 "bedrock-runtime" => "bedrock",
765 "apigatewayv2" => "apigateway",
773 "opensearch" => "es",
781 "appconfigdata" => "appconfig",
787 "airflow" => "mwaa",
792 "mobiletargeting" => "pinpoint",
797 other => other,
798 }
799}
800
801pub fn parse_query_body(body: &Bytes) -> HashMap<String, String> {
803 decode_form_urlencoded(body)
804}
805
806pub fn flatten_json_to_query(body: &Bytes) -> HashMap<String, String> {
823 let mut out = HashMap::new();
824 let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
825 return out;
826 };
827 if value.is_object() {
828 flatten_json_value("", &value, &mut out);
829 }
830 out
831}
832
833fn flatten_json_value(prefix: &str, value: &serde_json::Value, out: &mut HashMap<String, String>) {
834 match value {
835 serde_json::Value::Object(map) => {
836 for (k, v) in map {
837 let child = if prefix.is_empty() {
838 k.clone()
839 } else {
840 format!("{prefix}.{k}")
841 };
842 flatten_json_value(&child, v, out);
843 }
844 }
845 serde_json::Value::Array(items) => {
846 for (i, v) in items.iter().enumerate() {
847 let child = format!("{prefix}.member.{}", i + 1);
848 flatten_json_value(&child, v, out);
849 }
850 }
851 serde_json::Value::Null => {}
852 serde_json::Value::String(s) => {
853 out.insert(prefix.to_string(), s.clone());
854 }
855 serde_json::Value::Bool(b) => {
856 out.insert(prefix.to_string(), b.to_string());
857 }
858 serde_json::Value::Number(n) => {
859 out.insert(prefix.to_string(), n.to_string());
860 }
861 }
862}
863
864pub(crate) fn form_urlencoded_pairs(input: &str) -> Vec<(String, String)> {
870 let mut pairs = Vec::new();
871 for pair in input.split('&') {
872 if pair.is_empty() {
873 continue;
874 }
875 let (key, value) = match pair.find('=') {
876 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
877 None => (pair, ""),
878 };
879 pairs.push((url_decode(key), url_decode(value)));
880 }
881 pairs
882}
883
884fn decode_form_urlencoded(input: &[u8]) -> HashMap<String, String> {
885 let s = std::str::from_utf8(input).unwrap_or("");
886 let mut result = HashMap::new();
887 for pair in s.split('&') {
888 if pair.is_empty() {
889 continue;
890 }
891 let (key, value) = match pair.find('=') {
892 Some(pos) => (&pair[..pos], &pair[pos + 1..]),
893 None => (pair, ""),
894 };
895 result.insert(url_decode(key), url_decode(value));
896 }
897 result
898}
899
900fn url_decode(input: &str) -> String {
901 let mut buf: Vec<u8> = Vec::with_capacity(input.len());
907 let mut bytes = input.bytes();
908 while let Some(b) = bytes.next() {
909 match b {
910 b'+' => buf.push(b' '),
911 b'%' => {
912 let high = bytes.next().and_then(from_hex);
913 let low = bytes.next().and_then(from_hex);
914 if let (Some(h), Some(l)) = (high, low) {
918 buf.push((h << 4) | l);
919 }
920 }
921 _ => buf.push(b),
922 }
923 }
924 String::from_utf8_lossy(&buf).into_owned()
925}
926
927fn from_hex(b: u8) -> Option<u8> {
928 match b {
929 b'0'..=b'9' => Some(b - b'0'),
930 b'a'..=b'f' => Some(b - b'a' + 10),
931 b'A'..=b'F' => Some(b - b'A' + 10),
932 _ => None,
933 }
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 #[test]
941 fn form_urlencoded_pairs_preserves_repeated_keys() {
942 let pairs = form_urlencoded_pairs("Id=a&Id=b&Id=c&Other=x");
945 let ids: Vec<&str> = pairs
946 .iter()
947 .filter(|(k, _)| k == "Id")
948 .map(|(_, v)| v.as_str())
949 .collect();
950 assert_eq!(ids, vec!["a", "b", "c"]);
951 assert_eq!(
953 decode_form_urlencoded(b"Id=a&Id=b&Id=c").get("Id").unwrap(),
954 "c"
955 );
956 }
957
958 #[test]
959 fn form_urlencoded_pairs_decodes_percent_and_plus() {
960 let pairs = form_urlencoded_pairs("q=caf%C3%A9+bar&empty=");
961 assert_eq!(pairs[0], ("q".to_string(), "café bar".to_string()));
962 assert_eq!(pairs[1], ("empty".to_string(), String::new()));
963 }
964
965 #[test]
966 fn parse_amz_target_events() {
967 let result = parse_amz_target("AWSEvents.PutEvents").unwrap();
968 assert_eq!(result.service, "events");
969 assert_eq!(result.action, "PutEvents");
970 assert_eq!(result.protocol, AwsProtocol::Json);
971 }
972
973 #[test]
974 fn parse_amz_target_ssm() {
975 let result = parse_amz_target("AmazonSSM.GetParameter").unwrap();
976 assert_eq!(result.service, "ssm");
977 assert_eq!(result.action, "GetParameter");
978 }
979
980 #[test]
981 fn parse_amz_target_kinesis() {
982 let result = parse_amz_target("Kinesis_20131202.ListStreams").unwrap();
983 assert_eq!(result.service, "kinesis");
984 assert_eq!(result.action, "ListStreams");
985 assert_eq!(result.protocol, AwsProtocol::Json);
986 }
987
988 #[test]
989 fn parse_query_body_basic() {
990 let body = Bytes::from(
991 "Action=SendMessage&QueueUrl=http%3A%2F%2Flocalhost%3A4566%2Fqueue&MessageBody=hello",
992 );
993 let params = parse_query_body(&body);
994 assert_eq!(params.get("Action").unwrap(), "SendMessage");
995 assert_eq!(params.get("MessageBody").unwrap(), "hello");
996 }
997
998 #[test]
999 fn parse_query_body_empty_returns_empty_map() {
1000 let body = Bytes::from("");
1001 let params = parse_query_body(&body);
1002 assert!(params.is_empty());
1003 }
1004
1005 #[test]
1006 fn parse_query_body_duplicate_keys_last_wins() {
1007 let body = Bytes::from("key=a&key=b");
1008 let params = parse_query_body(&body);
1009 assert_eq!(params.get("key").unwrap(), "b");
1010 }
1011
1012 #[test]
1013 fn parse_query_body_single_key() {
1014 let body = Bytes::from("key=value");
1015 let params = parse_query_body(&body);
1016 assert_eq!(params.get("key").unwrap(), "value");
1017 }
1018
1019 #[test]
1020 fn url_decode_plain_ascii() {
1021 assert_eq!(url_decode("hello"), "hello");
1022 assert_eq!(url_decode("Action=SendMessage"), "Action=SendMessage");
1023 }
1024
1025 #[test]
1026 fn url_decode_plus_is_space() {
1027 assert_eq!(url_decode("hello+world"), "hello world");
1028 assert_eq!(url_decode("a+b+c"), "a b c");
1029 }
1030
1031 #[test]
1032 fn url_decode_multibyte_utf8_accents() {
1033 assert_eq!(url_decode("caf%C3%A9"), "café");
1035 }
1036
1037 #[test]
1038 fn url_decode_multibyte_utf8_cjk() {
1039 assert_eq!(url_decode("%E6%97%A5%E6%9C%AC"), "日本");
1041 }
1042
1043 #[test]
1044 fn url_decode_multibyte_utf8_emoji() {
1045 assert_eq!(url_decode("%F0%9F%9A%80"), "🚀");
1047 }
1048
1049 #[test]
1050 fn url_decode_mixed_ascii_and_multibyte() {
1051 assert_eq!(url_decode("Tag+%3D+caf%C3%A9%21"), "Tag = café!");
1052 }
1053
1054 #[test]
1055 fn url_decode_malformed_percent_is_graceful() {
1056 assert_eq!(url_decode("100%"), "100");
1058 assert_eq!(url_decode("a%zz"), "a");
1059 assert_eq!(url_decode("a%4"), "a");
1060 assert_eq!(url_decode("x%y"), "x");
1062 }
1063
1064 #[test]
1065 fn url_decode_invalid_utf8_bytes_are_lossy_no_panic() {
1066 let out = url_decode("bad%FFbyte");
1068 assert!(out.starts_with("bad"));
1069 assert!(out.ends_with("byte"));
1070 }
1071
1072 #[test]
1073 fn parse_query_body_multibyte_value_round_trips() {
1074 let body = Bytes::from("Tag.Value=caf%C3%A9&Name=%E6%97%A5%E6%9C%AC");
1075 let params = parse_query_body(&body);
1076 assert_eq!(params.get("Tag.Value").unwrap(), "café");
1077 assert_eq!(params.get("Name").unwrap(), "日本");
1078 }
1079
1080 #[test]
1081 fn parse_amz_target_ecs() {
1082 let result = parse_amz_target("AmazonEC2ContainerServiceV20141113.ListClusters").unwrap();
1083 assert_eq!(result.service, "ecs");
1084 assert_eq!(result.action, "ListClusters");
1085 assert_eq!(result.protocol, AwsProtocol::Json);
1086 }
1087
1088 #[test]
1089 fn parse_amz_target_invalid_returns_none() {
1090 assert!(parse_amz_target("NoDotHere").is_none());
1091 assert!(parse_amz_target("").is_none());
1092 }
1093
1094 #[test]
1095 fn parse_amz_target_cloudwatch_json() {
1096 let result = parse_amz_target("GraniteServiceVersion20100801.PutMetricData").unwrap();
1098 assert_eq!(result.service, "monitoring");
1099 assert_eq!(result.action, "PutMetricData");
1100 assert_eq!(result.protocol, AwsProtocol::Json);
1101 }
1102
1103 #[test]
1104 fn flatten_json_to_query_nested() {
1105 let body = Bytes::from(
1106 serde_json::json!({
1107 "Namespace": "MyApp",
1108 "MetricData": [{
1109 "MetricName": "Latency",
1110 "Value": 12.5,
1111 "StatisticValues": {"SampleCount": 3, "Sum": 10},
1112 "Dimensions": [{"Name": "Endpoint", "Value": "/api"}]
1113 }]
1114 })
1115 .to_string(),
1116 );
1117 let flat = flatten_json_to_query(&body);
1118 assert_eq!(flat.get("Namespace").unwrap(), "MyApp");
1119 assert_eq!(
1120 flat.get("MetricData.member.1.MetricName").unwrap(),
1121 "Latency"
1122 );
1123 assert_eq!(flat.get("MetricData.member.1.Value").unwrap(), "12.5");
1124 assert_eq!(
1125 flat.get("MetricData.member.1.StatisticValues.SampleCount")
1126 .unwrap(),
1127 "3"
1128 );
1129 assert_eq!(
1130 flat.get("MetricData.member.1.Dimensions.member.1.Name")
1131 .unwrap(),
1132 "Endpoint"
1133 );
1134 assert_eq!(
1135 flat.get("MetricData.member.1.Dimensions.member.1.Value")
1136 .unwrap(),
1137 "/api"
1138 );
1139 }
1140
1141 #[test]
1142 fn flatten_json_to_query_non_object_is_empty() {
1143 assert!(flatten_json_to_query(&Bytes::from_static(b"[]")).is_empty());
1144 assert!(flatten_json_to_query(&Bytes::from_static(b"not json")).is_empty());
1145 }
1146
1147 #[test]
1148 fn parse_amz_target_various_prefixes() {
1149 assert_eq!(
1150 parse_amz_target("AmazonSQS.SendMessage").unwrap().service,
1151 "sqs"
1152 );
1153 assert_eq!(
1154 parse_amz_target("AmazonSNS.Publish").unwrap().service,
1155 "sns"
1156 );
1157 assert_eq!(
1158 parse_amz_target("DynamoDB_20120810.GetItem")
1159 .unwrap()
1160 .service,
1161 "dynamodb"
1162 );
1163 assert_eq!(
1164 parse_amz_target("Logs_20140328.PutLogEvents")
1165 .unwrap()
1166 .service,
1167 "logs"
1168 );
1169 assert_eq!(
1170 parse_amz_target("secretsmanager.GetSecretValue")
1171 .unwrap()
1172 .service,
1173 "secretsmanager"
1174 );
1175 assert_eq!(
1176 parse_amz_target("TrentService.Encrypt").unwrap().service,
1177 "kms"
1178 );
1179 assert_eq!(
1180 parse_amz_target("AWSCognitoIdentityProviderService.InitiateAuth")
1181 .unwrap()
1182 .service,
1183 "cognito-idp"
1184 );
1185 assert_eq!(
1186 parse_amz_target("AWSStepFunctions.StartExecution")
1187 .unwrap()
1188 .service,
1189 "states"
1190 );
1191 assert_eq!(
1192 parse_amz_target("AWSOrganizationsV20161128.CreateOrganization")
1193 .unwrap()
1194 .service,
1195 "organizations"
1196 );
1197 assert!(parse_amz_target("UnknownServicePrefix.Action").is_none());
1198 }
1199
1200 #[test]
1201 fn infer_service_from_action_maps_sts() {
1202 assert_eq!(
1203 infer_service_from_action("AssumeRole").as_deref(),
1204 Some("sts")
1205 );
1206 assert_eq!(
1207 infer_service_from_action("GetCallerIdentity").as_deref(),
1208 Some("sts")
1209 );
1210 }
1211
1212 #[test]
1213 fn infer_service_from_action_maps_iam() {
1214 assert_eq!(
1215 infer_service_from_action("CreateUser").as_deref(),
1216 Some("iam")
1217 );
1218 assert_eq!(
1219 infer_service_from_action("ListRoles").as_deref(),
1220 Some("iam")
1221 );
1222 }
1223
1224 #[test]
1225 fn infer_service_from_action_maps_ses() {
1226 assert_eq!(
1227 infer_service_from_action("SendEmail").as_deref(),
1228 Some("ses")
1229 );
1230 assert_eq!(
1231 infer_service_from_action("ListIdentities").as_deref(),
1232 Some("ses")
1233 );
1234 }
1235
1236 #[test]
1237 fn infer_service_from_action_maps_sns_confirmation_flow() {
1238 assert_eq!(
1241 infer_service_from_action("ConfirmSubscription").as_deref(),
1242 Some("sns")
1243 );
1244 assert_eq!(
1245 infer_service_from_action("Unsubscribe").as_deref(),
1246 Some("sns")
1247 );
1248 }
1249
1250 #[test]
1251 fn detect_service_routes_unsigned_confirm_subscription_to_sns() {
1252 let mut headers = HeaderMap::new();
1255 headers.insert("host", "localhost:4566".parse().unwrap());
1256 let mut query_params = HashMap::new();
1257 query_params.insert("Action".to_string(), "ConfirmSubscription".to_string());
1258 query_params.insert(
1259 "TopicArn".to_string(),
1260 "arn:aws:sns:us-east-1:000000000000:t".to_string(),
1261 );
1262 query_params.insert("Token".to_string(), "abc123".to_string());
1263
1264 let detected = detect_service(&headers, &query_params, &Bytes::new())
1265 .expect("ConfirmSubscription must route to a service");
1266 assert_eq!(detected.service, "sns");
1267 assert_eq!(detected.action, "ConfirmSubscription");
1268 assert_eq!(detected.protocol, AwsProtocol::Query);
1269 }
1270
1271 #[test]
1272 fn infer_service_from_action_unknown_returns_none() {
1273 assert!(infer_service_from_action("NotARealAction").is_none());
1274 }
1275
1276 #[test]
1277 fn rest_protocol_for_returns_none_for_non_rest_service() {
1278 assert!(rest_protocol_for("sqs").is_none());
1279 }
1280
1281 #[test]
1282 fn url_decode_handles_percent_and_plus() {
1283 assert_eq!(url_decode("hello+world"), "hello world");
1284 assert_eq!(url_decode("hello%20world"), "hello world");
1285 assert_eq!(url_decode("100%25"), "100%");
1286 }
1287
1288 #[test]
1289 fn url_decode_ignores_malformed_percent() {
1290 assert_eq!(url_decode("%ZZ"), "");
1291 }
1292
1293 #[test]
1294 fn from_hex_valid_digits() {
1295 assert_eq!(from_hex(b'0'), Some(0));
1296 assert_eq!(from_hex(b'9'), Some(9));
1297 assert_eq!(from_hex(b'a'), Some(10));
1298 assert_eq!(from_hex(b'F'), Some(15));
1299 }
1300
1301 #[test]
1302 fn from_hex_invalid_returns_none() {
1303 assert!(from_hex(b'g').is_none());
1304 assert!(from_hex(b' ').is_none());
1305 }
1306
1307 #[test]
1308 fn detect_service_via_amz_target() {
1309 let mut headers = HeaderMap::new();
1310 headers.insert("x-amz-target", "AmazonSSM.GetParameter".parse().unwrap());
1311 let query = HashMap::new();
1312 let body = Bytes::new();
1313 let detected = detect_service(&headers, &query, &body).unwrap();
1314 assert_eq!(detected.service, "ssm");
1315 assert_eq!(detected.action, "GetParameter");
1316 }
1317
1318 #[test]
1319 fn detect_service_via_query_action_with_inferred_service() {
1320 let headers = HeaderMap::new();
1321 let mut query = HashMap::new();
1322 query.insert("Action".to_string(), "AssumeRole".to_string());
1323 let body = Bytes::new();
1324 let detected = detect_service(&headers, &query, &body).unwrap();
1325 assert_eq!(detected.service, "sts");
1326 assert_eq!(detected.action, "AssumeRole");
1327 assert_eq!(detected.protocol, AwsProtocol::Query);
1328 }
1329
1330 #[test]
1331 fn detect_service_via_form_body() {
1332 let headers = HeaderMap::new();
1333 let query = HashMap::new();
1334 let body = Bytes::from("Action=SendEmail&Source=x%40y.com");
1335 let detected = detect_service(&headers, &query, &body).unwrap();
1336 assert_eq!(detected.service, "ses");
1337 assert_eq!(detected.action, "SendEmail");
1338 }
1339
1340 #[test]
1341 fn detect_service_via_sigv2_presigned() {
1342 let headers = HeaderMap::new();
1343 let mut query = HashMap::new();
1344 query.insert("AWSAccessKeyId".to_string(), "AKID".to_string());
1345 query.insert("Signature".to_string(), "sig".to_string());
1346 query.insert("Expires".to_string(), "1234567890".to_string());
1347 let body = Bytes::new();
1348 let detected = detect_service(&headers, &query, &body).unwrap();
1349 assert_eq!(detected.service, "s3");
1350 assert_eq!(detected.protocol, AwsProtocol::Rest);
1351 }
1352
1353 #[test]
1354 fn detect_service_via_sigv4_presigned_credential() {
1355 let headers = HeaderMap::new();
1356 let mut query = HashMap::new();
1357 query.insert(
1358 "X-Amz-Credential".to_string(),
1359 "AKID/20240101/us-east-1/s3/aws4_request".to_string(),
1360 );
1361 let body = Bytes::new();
1362 let detected = detect_service(&headers, &query, &body).unwrap();
1363 assert_eq!(detected.service, "s3");
1364 assert_eq!(detected.protocol, AwsProtocol::Rest);
1365 }
1366
1367 #[test]
1368 fn detect_service_unknown_returns_none() {
1369 let headers = HeaderMap::new();
1370 let query = HashMap::new();
1371 let body = Bytes::new();
1372 assert!(detect_service(&headers, &query, &body).is_none());
1373 }
1374
1375 #[test]
1376 fn normalize_service_name_aliases_apigatewayv2_to_apigateway() {
1377 assert_eq!(normalize_service_name("apigatewayv2"), "apigateway");
1382 }
1383
1384 #[test]
1385 fn normalize_service_name_aliases_bedrock_runtime_to_bedrock() {
1386 assert_eq!(normalize_service_name("bedrock-runtime"), "bedrock");
1391 }
1392
1393 #[test]
1394 fn normalize_service_name_passes_through_unaliased_services() {
1395 assert_eq!(normalize_service_name("bedrock"), "bedrock");
1399 assert_eq!(normalize_service_name("s3"), "s3");
1400 assert_eq!(normalize_service_name("lambda"), "lambda");
1401 assert_eq!(normalize_service_name(""), "");
1402 assert_eq!(
1403 normalize_service_name("unknown-future-service"),
1404 "unknown-future-service"
1405 );
1406 }
1407
1408 #[test]
1409 fn detect_service_via_authorization_header_normalizes_bedrock_runtime() {
1410 let mut headers = HeaderMap::new();
1415 headers.insert(
1416 "authorization",
1417 "AWS4-HMAC-SHA256 \
1418 Credential=AKID/20240101/us-east-1/bedrock-runtime/aws4_request, \
1419 SignedHeaders=host, Signature=abc"
1420 .parse()
1421 .unwrap(),
1422 );
1423 let query = HashMap::new();
1424 let body = Bytes::new();
1425 let detected = detect_service(&headers, &query, &body).unwrap();
1426 assert_eq!(detected.service, "bedrock");
1427 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1428 }
1429
1430 #[test]
1431 fn detect_service_via_sigv4_presigned_credential_normalizes_bedrock_runtime() {
1432 let headers = HeaderMap::new();
1436 let mut query = HashMap::new();
1437 query.insert(
1438 "X-Amz-Credential".to_string(),
1439 "AKID/20240101/us-east-1/bedrock-runtime/aws4_request".to_string(),
1440 );
1441 let body = Bytes::new();
1442 let detected = detect_service(&headers, &query, &body).unwrap();
1443 assert_eq!(detected.service, "bedrock");
1444 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1445 }
1446
1447 #[test]
1448 fn parse_routing_host_localstack_basic() {
1449 let h = parse_routing_host("sqs.us-east-1.localhost.localstack.cloud").unwrap();
1450 assert_eq!(h.service, "sqs");
1451 assert_eq!(h.region, "us-east-1");
1452 assert!(h.bucket.is_none());
1453 }
1454
1455 #[test]
1456 fn parse_routing_host_localstack_with_port() {
1457 let h = parse_routing_host("lambda.eu-west-1.localhost.localstack.cloud:4566").unwrap();
1458 assert_eq!(h.service, "lambda");
1459 assert_eq!(h.region, "eu-west-1");
1460 assert!(h.bucket.is_none());
1461 }
1462
1463 #[test]
1464 fn parse_routing_host_case_insensitive() {
1465 let h = parse_routing_host("SQS.US-EAST-1.LOCALHOST.LOCALSTACK.CLOUD:4566").unwrap();
1466 assert_eq!(h.service, "sqs");
1467 assert_eq!(h.region, "us-east-1");
1468
1469 let h = parse_routing_host("LAMBDA.US-EAST-1.AMAZONAWS.COM").unwrap();
1470 assert_eq!(h.service, "lambda");
1471 assert_eq!(h.region, "us-east-1");
1472 }
1473
1474 #[test]
1475 fn parse_routing_host_localstack_s3_virtual_hosted() {
1476 let h =
1477 parse_routing_host("my-bucket.s3.us-east-1.localhost.localstack.cloud:4566").unwrap();
1478 assert_eq!(h.service, "s3");
1479 assert_eq!(h.region, "us-east-1");
1480 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1481 }
1482
1483 #[test]
1484 fn parse_routing_host_localstack_s3_vhost_bucket_with_dots() {
1485 let h = parse_routing_host("a.b.c.s3.us-east-1.localhost.localstack.cloud").unwrap();
1486 assert_eq!(h.service, "s3");
1487 assert_eq!(h.region, "us-east-1");
1488 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1489 }
1490
1491 #[test]
1492 fn parse_routing_host_aws_service_region() {
1493 let h = parse_routing_host("sqs.us-east-1.amazonaws.com").unwrap();
1494 assert_eq!(h.service, "sqs");
1495 assert_eq!(h.region, "us-east-1");
1496 assert!(h.bucket.is_none());
1497
1498 let h = parse_routing_host("dynamodb.eu-west-2.amazonaws.com:443").unwrap();
1499 assert_eq!(h.service, "dynamodb");
1500 assert_eq!(h.region, "eu-west-2");
1501 }
1502
1503 #[test]
1504 fn parse_routing_host_aws_s3_path_style_modern() {
1505 let h = parse_routing_host("s3.us-east-1.amazonaws.com").unwrap();
1506 assert_eq!(h.service, "s3");
1507 assert_eq!(h.region, "us-east-1");
1508 assert!(h.bucket.is_none());
1509 }
1510
1511 #[test]
1512 fn parse_routing_host_aws_s3_virtual_hosted_modern() {
1513 let h = parse_routing_host("my-bucket.s3.us-east-1.amazonaws.com").unwrap();
1514 assert_eq!(h.service, "s3");
1515 assert_eq!(h.region, "us-east-1");
1516 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1517 }
1518
1519 #[test]
1520 fn parse_routing_host_aws_s3_vhost_bucket_with_dots() {
1521 let h = parse_routing_host("a.b.c.s3.us-east-1.amazonaws.com").unwrap();
1522 assert_eq!(h.service, "s3");
1523 assert_eq!(h.region, "us-east-1");
1524 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1525 }
1526
1527 #[test]
1528 fn parse_routing_host_aws_s3_legacy_global() {
1529 let h = parse_routing_host("s3.amazonaws.com").unwrap();
1532 assert_eq!(h.service, "s3");
1533 assert_eq!(h.region, "us-east-1");
1534 assert!(h.bucket.is_none());
1535
1536 let h = parse_routing_host("my-bucket.s3.amazonaws.com").unwrap();
1537 assert_eq!(h.service, "s3");
1538 assert_eq!(h.region, "us-east-1");
1539 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1540 }
1541
1542 #[test]
1543 fn parse_routing_host_aws_s3_legacy_global_dotted_bucket() {
1544 let h = parse_routing_host("a.b.c.s3.amazonaws.com").unwrap();
1547 assert_eq!(h.service, "s3");
1548 assert_eq!(h.region, "us-east-1");
1549 assert_eq!(h.bucket.as_deref(), Some("a.b.c"));
1550 }
1551
1552 #[test]
1553 fn parse_routing_host_aws_s3_dash_separated() {
1554 let h = parse_routing_host("s3-us-west-2.amazonaws.com").unwrap();
1556 assert_eq!(h.service, "s3");
1557 assert_eq!(h.region, "us-west-2");
1558 assert!(h.bucket.is_none());
1559
1560 let h = parse_routing_host("my-bucket.s3-us-west-2.amazonaws.com").unwrap();
1561 assert_eq!(h.service, "s3");
1562 assert_eq!(h.region, "us-west-2");
1563 assert_eq!(h.bucket.as_deref(), Some("my-bucket"));
1564 }
1565
1566 #[test]
1567 fn parse_routing_host_rejects_plain_localhost() {
1568 assert!(parse_routing_host("localhost:4566").is_none());
1569 assert!(parse_routing_host("127.0.0.1:4566").is_none());
1570 }
1571
1572 #[test]
1573 fn parse_routing_host_rejects_unknown_suffix() {
1574 assert!(parse_routing_host("sqs.us-east-1.example.com").is_none());
1575 assert!(parse_routing_host("s3.us-east-1.aws").is_none());
1576 }
1577
1578 #[test]
1579 fn parse_routing_host_empty_and_malformed_rejected() {
1580 assert!(parse_routing_host("").is_none());
1581 assert!(parse_routing_host(".localhost.localstack.cloud").is_none());
1582 assert!(parse_routing_host("..localhost.localstack.cloud").is_none());
1583 assert!(parse_routing_host("sqs.localhost.localstack.cloud").is_none());
1584 assert!(parse_routing_host("foo.bar.baz.localhost.localstack.cloud").is_none());
1585 assert!(parse_routing_host(".amazonaws.com").is_none());
1586 assert!(parse_routing_host("amazonaws.com").is_none());
1587 }
1588
1589 #[test]
1590 fn parse_routing_host_bare_s3_accesspoint_does_not_panic() {
1591 assert!(parse_routing_host("s3-accesspoint").is_none());
1595 }
1596
1597 #[test]
1598 fn detect_service_via_host_for_rest_service() {
1599 let mut headers = HeaderMap::new();
1600 headers.insert(
1601 "host",
1602 "s3.us-east-1.localhost.localstack.cloud:4566"
1603 .parse()
1604 .unwrap(),
1605 );
1606 let query = HashMap::new();
1607 let body = Bytes::new();
1608 let detected = detect_service(&headers, &query, &body).unwrap();
1609 assert_eq!(detected.service, "s3");
1610 assert_eq!(detected.protocol, AwsProtocol::Rest);
1611 }
1612
1613 #[test]
1614 fn detect_service_via_host_for_rest_json_service() {
1615 let mut headers = HeaderMap::new();
1616 headers.insert(
1617 "host",
1618 "lambda.us-east-1.localhost.localstack.cloud:4566"
1619 .parse()
1620 .unwrap(),
1621 );
1622 let query = HashMap::new();
1623 let body = Bytes::new();
1624 let detected = detect_service(&headers, &query, &body).unwrap();
1625 assert_eq!(detected.service, "lambda");
1626 assert_eq!(detected.protocol, AwsProtocol::RestJson);
1627 }
1628
1629 #[test]
1630 fn detect_service_via_host_plus_query_action() {
1631 let mut headers = HeaderMap::new();
1632 headers.insert(
1633 "host",
1634 "sqs.us-east-1.localhost.localstack.cloud:4566"
1635 .parse()
1636 .unwrap(),
1637 );
1638 let mut query = HashMap::new();
1639 query.insert("Action".to_string(), "ListQueues".to_string());
1640 let body = Bytes::new();
1641 let detected = detect_service(&headers, &query, &body).unwrap();
1642 assert_eq!(detected.service, "sqs");
1643 assert_eq!(detected.action, "ListQueues");
1644 assert_eq!(detected.protocol, AwsProtocol::Query);
1645 }
1646
1647 #[test]
1648 fn detect_service_sigv4_wins_over_host() {
1649 let mut headers = HeaderMap::new();
1650 headers.insert(
1651 "authorization",
1652 "AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, \
1653 SignedHeaders=host, Signature=abc"
1654 .parse()
1655 .unwrap(),
1656 );
1657 headers.insert(
1658 "host",
1659 "lambda.us-east-1.localhost.localstack.cloud:4566"
1660 .parse()
1661 .unwrap(),
1662 );
1663 let query = HashMap::new();
1664 let body = Bytes::new();
1665 let detected = detect_service(&headers, &query, &body).unwrap();
1666 assert_eq!(detected.service, "s3");
1668 assert_eq!(detected.protocol, AwsProtocol::Rest);
1669 }
1670
1671 #[test]
1672 fn detect_service_host_for_virtual_hosted_s3() {
1673 let mut headers = HeaderMap::new();
1674 headers.insert(
1675 "host",
1676 "my-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1677 .parse()
1678 .unwrap(),
1679 );
1680 let query = HashMap::new();
1681 let body = Bytes::new();
1682 let detected = detect_service(&headers, &query, &body).unwrap();
1683 assert_eq!(detected.service, "s3");
1684 assert_eq!(detected.protocol, AwsProtocol::Rest);
1685 }
1686}