1use axum::body::Body;
2use axum::extract::{ConnectInfo, Extension, Query};
3use axum::http::{Request, StatusCode};
4use axum::response::Response;
5use bytes::Bytes;
6use std::collections::HashMap;
7use std::net::SocketAddr;
8use std::sync::Arc;
9
10use crate::auth::{
11 is_root_bypass, ConditionContext, CredentialResolver, IamMode, IamPolicyEvaluator, Principal,
12 PrincipalType, ResourcePolicyProvider,
13};
14use crate::protocol::{self, AwsProtocol};
15use crate::registry::ServiceRegistry;
16use crate::service::{AwsRequest, ResponseBody};
17
18pub async fn dispatch(
20 ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
21 Extension(registry): Extension<Arc<ServiceRegistry>>,
22 Extension(config): Extension<Arc<DispatchConfig>>,
23 Query(query_params): Query<HashMap<String, String>>,
24 request: Request<Body>,
25) -> Response<Body> {
26 let remote_addr = Some(remote_addr);
27 let request_id = uuid::Uuid::new_v4().to_string();
28
29 let (parts, body) = request.into_parts();
30
31 let stream_route = streaming_route(
37 &parts.method,
38 parts.uri.path(),
39 &parts.headers,
40 &query_params,
41 );
42 let header_only = protocol::detect_service_headers_only(&parts.headers, &query_params);
43 let stream_dispatch = match (&stream_route, &header_only) {
44 (Some(sr), Some(detected)) if sr.0 == detected.service => Some(detected.clone()),
47 (Some((service, _)), None) if *service == "ecr" => Some(protocol::DetectedRequest {
53 service: "ecr".to_string(),
54 action: String::new(),
55 protocol: AwsProtocol::Rest,
56 }),
57 _ => None,
58 };
59
60 let (body_bytes, body_stream) = if stream_dispatch.is_some() {
61 (Bytes::new(), Some(body))
62 } else {
63 let max_body_bytes = max_request_body_bytes();
68 match axum::body::to_bytes(body, max_body_bytes).await {
69 Ok(b) => (b, None),
70 Err(_) => {
71 return build_error_response(
72 StatusCode::PAYLOAD_TOO_LARGE,
73 "RequestEntityTooLarge",
74 "Request body too large",
75 &request_id,
76 AwsProtocol::Query,
77 );
78 }
79 }
80 };
81
82 let detected = if let Some(d) = stream_dispatch {
84 d
85 } else {
86 match protocol::detect_service(&parts.headers, &query_params, &body_bytes) {
87 Some(d) => d,
88 None => {
89 if let Some(target) = parts
95 .headers
96 .get("x-amz-target")
97 .and_then(|v| v.to_str().ok())
98 {
99 return build_error_response(
100 StatusCode::BAD_REQUEST,
101 "UnknownOperationException",
102 &format!("The operation {target} is not recognized."),
103 &request_id,
104 AwsProtocol::Json,
105 );
106 }
107 if parts.method == http::Method::OPTIONS {
113 protocol::DetectedRequest {
114 service: "s3".to_string(),
115 action: String::new(),
116 protocol: AwsProtocol::Rest,
117 }
118 } else if parts.uri.path() == "/v2" || parts.uri.path().starts_with("/v2/") {
119 protocol::DetectedRequest {
123 service: "ecr".to_string(),
124 action: String::new(),
125 protocol: AwsProtocol::Rest,
126 }
127 } else if let Some(bucket) = anonymous_s3_bucket(&parts.uri, &config) {
128 tracing::debug!(bucket = %bucket, "routing unsigned request to S3 (existing bucket)");
135 protocol::DetectedRequest {
136 service: "s3".to_string(),
137 action: String::new(),
138 protocol: AwsProtocol::Rest,
139 }
140 } else if !parts.uri.path().starts_with("/_") {
141 protocol::DetectedRequest {
146 service: "apigateway".to_string(),
147 action: String::new(),
148 protocol: AwsProtocol::RestJson,
149 }
150 } else {
151 return build_error_response(
152 StatusCode::BAD_REQUEST,
153 "MissingAction",
154 "Could not determine target service or action from request",
155 &request_id,
156 AwsProtocol::Query,
157 );
158 }
159 }
160 }
161 };
162
163 let detected = if detected.service == "bedrock" {
167 let first_seg = parts.uri.path().split('/').nth(1);
168 if matches!(
169 first_seg,
170 Some(
171 "agents"
172 | "knowledgebases"
173 | "flows"
174 | "prompts"
175 | "tags"
176 | "retrieveAndGenerate"
177 | "retrieveAndGenerateStream"
178 | "optimize-prompt"
179 | "sessions"
180 | "invocations"
181 | "generate-query"
182 | "rerank"
183 )
184 ) {
185 let segs: Vec<&str> = parts.uri.path().split('/').collect();
187 let is_runtime = matches!(
188 segs.as_slice(),
189 ["", "agents", _, "agentAliases", _, ..] | ["", "flows", _, "aliases", _] | ["", "knowledgebases", _, "retrieve"] | ["", "retrieveAndGenerate"]
193 | ["", "retrieveAndGenerateStream"]
194 | ["", "optimize-prompt"]
195 | ["", "sessions", ..]
196 | ["", "invocations", ..]
197 | ["", "generate-query"]
198 | ["", "rerank"]
199 );
200 if is_runtime {
201 protocol::DetectedRequest {
202 service: "bedrock-agent-runtime".to_string(),
203 ..detected
204 }
205 } else {
206 protocol::DetectedRequest {
207 service: "bedrock-agent".to_string(),
208 ..detected
209 }
210 }
211 } else {
212 detected
213 }
214 } else {
215 detected
216 };
217
218 let detected = if detected.service == "rds" && user_agent_indicates_docdb(&parts.headers) {
227 protocol::DetectedRequest {
228 service: "docdb".to_string(),
229 ..detected
230 }
231 } else {
232 detected
233 };
234
235 let detected = if detected.service == "rds" && user_agent_indicates_neptune(&parts.headers) {
244 protocol::DetectedRequest {
245 service: "neptune".to_string(),
246 ..detected
247 }
248 } else {
249 detected
250 };
251
252 let service = match registry.get(&detected.service) {
254 Some(s) => s,
255 None => {
256 return build_error_response(
257 detected.protocol.error_status(),
258 "UnknownService",
259 &format!("Service '{}' is not available", detected.service),
260 &request_id,
261 detected.protocol,
262 );
263 }
264 };
265
266 let auth_header = parts
268 .headers
269 .get("authorization")
270 .and_then(|v| v.to_str().ok())
271 .unwrap_or("");
272 let header_info = fakecloud_aws::sigv4::parse_sigv4(auth_header);
273 let presigned_info = if header_info.is_none() {
274 fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params).map(|p| p.as_info())
276 } else {
277 None
278 };
279 let sigv4_info = header_info.or(presigned_info);
280 let access_key_id = sigv4_info
286 .as_ref()
287 .map(|info| info.access_key.clone())
288 .or_else(|| sigv2_presigned_access_key(&query_params));
289
290 let host_info = protocol::parse_routing_host_from_headers(&parts.headers);
296
297 let region = sigv4_info
298 .map(|info| info.region)
299 .or_else(|| host_info.as_ref().map(|h| h.region.clone()))
300 .or_else(|| extract_region_from_user_agent(&parts.headers))
301 .unwrap_or_else(|| config.region.clone());
302
303 let caller_akid = access_key_id.as_deref().unwrap_or("");
309 let resolved = if !caller_akid.is_empty() && !is_root_bypass(caller_akid) {
310 config
311 .credential_resolver
312 .as_ref()
313 .and_then(|r| r.resolve(caller_akid))
314 } else {
315 None
316 };
317 let caller_principal = resolved.as_ref().map(|r| r.principal.clone());
318 let caller_session_policies = resolved
319 .as_ref()
320 .map(|r| r.session_policies.clone())
321 .unwrap_or_default();
322
323 if config.verify_sigv4 && !is_root_bypass(caller_akid) && config.credential_resolver.is_some() {
328 let amz_date = parts
329 .headers
330 .get("x-amz-date")
331 .and_then(|v| v.to_str().ok());
332 let parsed = fakecloud_aws::sigv4::parse_sigv4_header(auth_header, amz_date)
333 .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params));
334 let parsed = match parsed {
335 Some(p) => p,
336 None => {
337 return build_error_response(
338 StatusCode::FORBIDDEN,
339 "IncompleteSignature",
340 "Request is missing or has a malformed AWS Signature",
341 &request_id,
342 detected.protocol,
343 );
344 }
345 };
346 let resolved_for_verify = match resolved.as_ref() {
347 Some(r) => r,
348 None => {
349 return build_error_response(
350 StatusCode::FORBIDDEN,
351 "InvalidClientTokenId",
352 "The security token included in the request is invalid",
353 &request_id,
354 detected.protocol,
355 );
356 }
357 };
358 let headers_vec = fakecloud_aws::sigv4::headers_from_http(&parts.headers);
359 let raw_query_for_verify = parts.uri.query().unwrap_or("").to_string();
360 let verify_req = fakecloud_aws::sigv4::VerifyRequest {
361 method: parts.method.as_str(),
362 path: parts.uri.path(),
363 query: &raw_query_for_verify,
364 headers: &headers_vec,
365 body: &body_bytes,
366 };
367 match fakecloud_aws::sigv4::verify(
368 &parsed,
369 &verify_req,
370 &resolved_for_verify.secret_access_key,
371 chrono::Utc::now(),
372 ) {
373 Ok(()) => {}
374 Err(fakecloud_aws::sigv4::SigV4Error::RequestTimeTooSkewed { .. }) => {
375 return build_error_response(
376 StatusCode::FORBIDDEN,
377 "RequestTimeTooSkewed",
378 "The difference between the request time and the current time is too large",
379 &request_id,
380 detected.protocol,
381 );
382 }
383 Err(fakecloud_aws::sigv4::SigV4Error::InvalidDate(msg)) => {
384 return build_error_response(
385 StatusCode::FORBIDDEN,
386 "IncompleteSignature",
387 &format!("Invalid x-amz-date: {msg}"),
388 &request_id,
389 detected.protocol,
390 );
391 }
392 Err(fakecloud_aws::sigv4::SigV4Error::Malformed(msg)) => {
393 return build_error_response(
394 StatusCode::FORBIDDEN,
395 "IncompleteSignature",
396 &format!("Malformed SigV4 signature: {msg}"),
397 &request_id,
398 detected.protocol,
399 );
400 }
401 Err(fakecloud_aws::sigv4::SigV4Error::SignatureMismatch) => {
402 return build_error_response(
403 StatusCode::FORBIDDEN,
404 "SignatureDoesNotMatch",
405 "The request signature we calculated does not match the signature you provided",
406 &request_id,
407 detected.protocol,
408 );
409 }
410 Err(fakecloud_aws::sigv4::SigV4Error::PresignedUrlExpired { .. }) => {
411 return build_error_response(
412 StatusCode::FORBIDDEN,
413 "AccessDenied",
414 "Request has expired",
415 &request_id,
416 detected.protocol,
417 );
418 }
419 Err(fakecloud_aws::sigv4::SigV4Error::InvalidPresignExpires(_)) => {
420 return build_error_response(
421 StatusCode::BAD_REQUEST,
422 "AuthorizationQueryParametersError",
423 "X-Amz-Expires must be a number between 1 and 604800 seconds",
424 &request_id,
425 detected.protocol,
426 );
427 }
428 }
429 }
430
431 let wire_path = parts.uri.path();
436 let path = if detected.service == "s3" {
437 if let Some(bucket) = host_info.as_ref().and_then(|h| h.bucket.as_deref()) {
438 let prefix_with_slash = format!("/{bucket}/");
439 let is_bucket_root = wire_path.trim_end_matches('/') == format!("/{bucket}");
440 if wire_path.starts_with(&prefix_with_slash) || is_bucket_root {
441 wire_path.to_string()
442 } else if wire_path == "/" || wire_path.is_empty() {
443 format!("/{bucket}")
444 } else {
445 format!("/{bucket}{wire_path}")
446 }
447 } else {
448 wire_path.to_string()
449 }
450 } else {
451 wire_path.to_string()
452 };
453 let raw_query = parts.uri.query().unwrap_or("").to_string();
454 let path_segments: Vec<String> = path
455 .split('/')
456 .filter(|s| !s.is_empty())
457 .map(|s| s.to_string())
458 .collect();
459
460 if detected.protocol == AwsProtocol::Json
462 && !body_bytes.is_empty()
463 && serde_json::from_slice::<serde_json::Value>(&body_bytes).is_err()
464 {
465 return build_error_response(
466 StatusCode::BAD_REQUEST,
467 "SerializationException",
468 "Start of structure or map found where not expected",
469 &request_id,
470 AwsProtocol::Json,
471 );
472 }
473
474 let mut all_params = query_params;
477 if matches!(
478 detected.protocol,
479 AwsProtocol::Query | AwsProtocol::Ec2Query
480 ) {
481 let body_params = protocol::parse_query_body(&body_bytes);
482 for (k, v) in body_params {
483 all_params.entry(k).or_insert(v);
484 }
485 }
486
487 if detected.protocol == AwsProtocol::Json && detected.service == "monitoring" {
492 let body_params = protocol::flatten_json_to_query(&body_bytes);
493 for (k, v) in body_params {
494 all_params.entry(k).or_insert(v);
495 }
496 }
497
498 let aws_request = AwsRequest {
499 service: detected.service.clone(),
500 action: detected.action.clone(),
501 region,
502 account_id: caller_principal
503 .as_ref()
504 .map(|p| p.account_id.clone())
505 .unwrap_or_else(|| config.account_id.clone()),
506 request_id: request_id.clone(),
507 headers: parts.headers,
508 query_params: all_params,
509 body: body_bytes,
510 body_stream: parking_lot::Mutex::new(body_stream),
511 path_segments,
512 raw_path: path,
513 raw_query,
514 method: parts.method,
515 is_query_protocol: matches!(
516 detected.protocol,
517 AwsProtocol::Query | AwsProtocol::Ec2Query
518 ),
519 access_key_id,
520 principal: caller_principal,
521 };
522
523 tracing::info!(
524 service = %aws_request.service,
525 action = %aws_request.action,
526 request_id = %aws_request.request_id,
527 "handling request"
528 );
529
530 if config.iam_mode.is_enabled()
537 && service.iam_enforceable()
538 && !is_root_bypass(aws_request.access_key_id.as_deref().unwrap_or(""))
539 {
540 if let Some(evaluator) = config.policy_evaluator.as_ref() {
541 if let Some(principal) = aws_request.principal.as_ref() {
542 if !principal.is_root() {
543 if let Some(iam_action) = service.iam_action_for(&aws_request) {
544 let mut condition_context = build_condition_context(
545 principal,
546 remote_addr,
547 &aws_request.region,
548 is_secure_transport(&aws_request.headers),
549 );
550 if let Some(rc) = resolved.as_ref() {
558 condition_context.aws_mfa_present = Some(rc.mfa_present);
559 condition_context.aws_token_issue_time = rc.token_issued_at;
560 condition_context.aws_federated_provider =
561 rc.federated_provider.clone();
562 if rc.mfa_present {
570 if let Some(issued) = rc.token_issued_at {
571 let age = chrono::Utc::now()
572 .signed_duration_since(issued)
573 .num_seconds()
574 .max(0);
575 condition_context.aws_mfa_age_seconds = Some(age);
576 }
577 }
578 }
579 condition_context.service_keys =
580 service.iam_condition_keys_for(&aws_request, &iam_action);
581
582 match service.resource_tags_for(&iam_action.resource) {
585 Some(tags) => condition_context.resource_tags = Some(tags),
586 None => tracing::debug!(
587 target: "fakecloud::iam::audit",
588 service = %detected.service,
589 resource = %iam_action.resource,
590 "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation"
591 ),
592 }
593 match service.request_tags_from(&aws_request, iam_action.action) {
595 Some(tags) => condition_context.request_tags = Some(tags),
596 None => tracing::debug!(
597 target: "fakecloud::iam::audit",
598 service = %detected.service,
599 action = %iam_action.action_string(),
600 "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation"
601 ),
602 }
603 condition_context.principal_tags = principal.tags.clone();
605
606 let resource_policy_json =
615 config.resource_policy_provider.as_ref().and_then(|p| {
616 p.resource_policy(&detected.service, &iam_action.resource)
617 });
618 let resource_account_id = config
628 .resource_policy_provider
629 .as_ref()
630 .and_then(|p| {
631 p.resource_owner_account(&detected.service, &iam_action.resource)
632 })
633 .or_else(|| parse_account_from_arn(&iam_action.resource))
634 .unwrap_or_else(|| principal.account_id.clone());
635 let scps = config
642 .scp_resolver
643 .as_ref()
644 .and_then(|r| r.scps_for(principal));
645 let decision = evaluator.evaluate_with_resource_policy(
646 principal,
647 &iam_action,
648 &condition_context,
649 resource_policy_json.as_deref(),
650 &resource_account_id,
651 &caller_session_policies,
652 scps.as_deref(),
653 );
654 if !decision.is_allow() {
655 tracing::warn!(
656 target: "fakecloud::iam::audit",
657 service = %detected.service,
658 action = %iam_action.action_string(),
659 resource = %iam_action.resource,
660 principal = %principal.arn,
661 resource_policy_present = resource_policy_json.is_some(),
662 decision = ?decision,
663 mode = %config.iam_mode,
664 request_id = %request_id,
665 "IAM policy evaluation denied request"
666 );
667 if config.iam_mode.is_strict() {
668 let context_summary = serde_json::json!({
681 "aws:PrincipalArn": principal.arn,
682 "aws:PrincipalAccount": principal.account_id,
683 "aws:RequestedRegion": condition_context
684 .aws_requested_region
685 .clone()
686 .unwrap_or_default(),
687 "aws:SecureTransport": condition_context
688 .aws_secure_transport
689 .unwrap_or(false),
690 "aws:Action": iam_action.action_string(),
691 "aws:Resource": iam_action.resource,
692 "decision": format!("{:?}", decision),
693 });
694 let action_string = iam_action.action_string();
695 let encoded = crate::auth_message::encode_deny(
696 matches!(decision, crate::auth::IamDecision::ExplicitDeny),
697 Some(&action_string),
698 Some(&principal.arn),
699 Vec::new(),
700 Some(context_summary),
701 );
702 return build_error_response(
703 StatusCode::FORBIDDEN,
704 "AccessDeniedException",
705 &format!(
706 "User: {} is not authorized to perform: {} on resource: {} Encoded authorization failure message: {}",
707 principal.arn,
708 iam_action.action_string(),
709 iam_action.resource,
710 encoded,
711 ),
712 &request_id,
713 detected.protocol,
714 );
715 }
716 }
719 } else {
720 tracing::warn!(
732 target: "fakecloud::iam::audit",
733 service = %detected.service,
734 action = %aws_request.action,
735 mode = %config.iam_mode,
736 request_id = %request_id,
737 "service is iam_enforceable but has no IamAction mapping for this action; denying under strict, allowing under soft"
738 );
739 if config.iam_mode.is_strict() {
740 return build_error_response(
741 StatusCode::FORBIDDEN,
742 "AccessDeniedException",
743 &format!(
744 "User: {} is not authorized to perform: {}: no IAM action mapping exists for this operation, so it cannot be authorized under strict IAM enforcement",
745 principal.arn, aws_request.action,
746 ),
747 &request_id,
748 detected.protocol,
749 );
750 }
751 }
754 }
755 } else if aws_request.access_key_id.is_none() {
756 if let Some(iam_action) = service.iam_action_for(&aws_request) {
772 let now = chrono::Utc::now();
773 let mut condition_context = ConditionContext {
774 aws_source_ip: remote_addr.map(|sa| sa.ip()),
775 aws_current_time: Some(now),
776 aws_epoch_time: Some(now.timestamp()),
777 aws_secure_transport: Some(is_secure_transport(&aws_request.headers)),
778 aws_requested_region: Some(aws_request.region.clone()),
779 ..Default::default()
780 };
781 condition_context.service_keys =
782 service.iam_condition_keys_for(&aws_request, &iam_action);
783 let resource_policy_json = config
784 .resource_policy_provider
785 .as_ref()
786 .and_then(|p| p.resource_policy(&detected.service, &iam_action.resource));
787 let policy_decision = evaluator.evaluate_anonymous(
788 &iam_action,
789 &condition_context,
790 resource_policy_json.as_deref(),
791 );
792 let policy_allows = policy_decision.is_allow();
793 let policy_explicit_deny =
798 matches!(policy_decision, crate::auth::IamDecision::ExplicitDeny);
799 let acl_allows = !policy_explicit_deny
800 && config.resource_policy_provider.as_ref().is_some_and(|p| {
801 p.public_acl_allows(
802 &detected.service,
803 &iam_action.resource,
804 iam_action.action,
805 )
806 });
807 if !policy_allows && !acl_allows {
808 tracing::warn!(
809 target: "fakecloud::iam::audit",
810 service = %detected.service,
811 action = %iam_action.action_string(),
812 resource = %iam_action.resource,
813 resource_policy_present = resource_policy_json.is_some(),
814 mode = %config.iam_mode,
815 request_id = %request_id,
816 "anonymous request denied: no public bucket policy or ACL grants the action"
817 );
818 if config.iam_mode.is_strict() {
819 return build_error_response(
820 StatusCode::FORBIDDEN,
821 "AccessDenied",
822 "Access Denied",
823 &request_id,
824 detected.protocol,
825 );
826 }
827 }
829 } else {
830 tracing::warn!(
837 target: "fakecloud::iam::audit",
838 service = %detected.service,
839 action = %aws_request.action,
840 mode = %config.iam_mode,
841 request_id = %request_id,
842 "anonymous request to iam_enforceable service has no IamAction mapping; denying under strict, allowing under soft"
843 );
844 if config.iam_mode.is_strict() {
845 return build_error_response(
846 StatusCode::FORBIDDEN,
847 "AccessDenied",
848 "Access Denied",
849 &request_id,
850 detected.protocol,
851 );
852 }
853 }
854 }
855 }
856 }
857
858 match service.handle(aws_request).await {
859 Ok(resp) => {
860 let mut builder = Response::builder()
861 .status(resp.status)
862 .header("x-amzn-requestid", &request_id)
863 .header("x-amz-request-id", &request_id);
864
865 if !resp.content_type.is_empty() {
866 builder = builder.header("content-type", &resp.content_type);
867 }
868
869 let has_content_length = resp
870 .headers
871 .iter()
872 .any(|(k, _)| k.as_str().eq_ignore_ascii_case("content-length"));
873
874 for (k, v) in &resp.headers {
875 builder = builder.header(k, v);
876 }
877
878 match resp.body {
879 ResponseBody::Bytes(b) => builder.body(Body::from(b)).unwrap(),
880 ResponseBody::File { file, size } => {
881 let stream = tokio_util::io::ReaderStream::new(file);
882 let body = Body::from_stream(stream);
883 if !has_content_length {
884 builder = builder.header("content-length", size.to_string());
885 }
886 builder.body(body).unwrap()
887 }
888 }
889 }
890 Err(err) => {
891 tracing::warn!(
892 service = %detected.service,
893 action = %detected.action,
894 error = %err,
895 "request failed"
896 );
897 let error_headers = err.response_headers().to_vec();
898 let mut resp = build_error_response_with_fields(
899 err.status(),
900 err.code(),
901 &err.message(),
902 &request_id,
903 detected.protocol,
904 err.extra_fields(),
905 );
906 for (k, v) in &error_headers {
907 if let (Ok(name), Ok(val)) = (
908 k.parse::<http::header::HeaderName>(),
909 v.parse::<http::header::HeaderValue>(),
910 ) {
911 resp.headers_mut().insert(name, val);
912 }
913 }
914 resp
915 }
916 }
917}
918
919#[derive(Clone)]
921pub struct DispatchConfig {
922 pub region: String,
923 pub account_id: String,
924 pub verify_sigv4: bool,
928 pub iam_mode: IamMode,
933 pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
937 pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
941 pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
948 pub scp_resolver: Option<Arc<dyn crate::auth::ScpResolver>>,
955}
956
957impl std::fmt::Debug for DispatchConfig {
958 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
959 f.debug_struct("DispatchConfig")
960 .field("region", &self.region)
961 .field("account_id", &self.account_id)
962 .field("verify_sigv4", &self.verify_sigv4)
963 .field("iam_mode", &self.iam_mode)
964 .field(
965 "credential_resolver",
966 &self
967 .credential_resolver
968 .as_ref()
969 .map(|_| "<CredentialResolver>"),
970 )
971 .field(
972 "policy_evaluator",
973 &self
974 .policy_evaluator
975 .as_ref()
976 .map(|_| "<IamPolicyEvaluator>"),
977 )
978 .field(
979 "resource_policy_provider",
980 &self
981 .resource_policy_provider
982 .as_ref()
983 .map(|_| "<ResourcePolicyProvider>"),
984 )
985 .field(
986 "scp_resolver",
987 &self.scp_resolver.as_ref().map(|_| "<ScpResolver>"),
988 )
989 .finish()
990 }
991}
992
993impl DispatchConfig {
994 pub fn new(region: impl Into<String>, account_id: impl Into<String>) -> Self {
997 Self {
998 region: region.into(),
999 account_id: account_id.into(),
1000 verify_sigv4: false,
1001 iam_mode: IamMode::Off,
1002 credential_resolver: None,
1003 policy_evaluator: None,
1004 resource_policy_provider: None,
1005 scp_resolver: None,
1006 }
1007 }
1008}
1009
1010fn streaming_route(
1030 method: &http::Method,
1031 path: &str,
1032 headers: &http::HeaderMap,
1033 query_params: &HashMap<String, String>,
1034) -> Option<(&'static str, &'static str)> {
1035 if (method == http::Method::PATCH || method == http::Method::PUT)
1037 && path.starts_with("/v2/")
1038 && path.contains("/blobs/uploads/")
1039 {
1040 return Some(("ecr", ""));
1041 }
1042
1043 if method == http::Method::PUT {
1048 let after = path.trim_start_matches('/');
1049 let virtual_hosted_s3 = protocol::parse_routing_host_from_headers(headers)
1055 .filter(|h| h.service == "s3" && h.bucket.is_some())
1056 .is_some();
1057 if after.is_empty() || (!virtual_hosted_s3 && !after.contains('/')) {
1058 return None;
1059 }
1060 let header_s3 = headers
1061 .get("authorization")
1062 .and_then(|v| v.to_str().ok())
1063 .and_then(fakecloud_aws::sigv4::parse_sigv4)
1064 .map(|info| info.service == "s3")
1065 .unwrap_or(false);
1066 let presigned_v4_s3 = query_params
1067 .get("X-Amz-Credential")
1068 .and_then(|c| c.split('/').nth(3).map(|s| s.to_string()))
1069 .map(|service| service == "s3")
1070 .unwrap_or(false);
1071 let presigned_v2 = query_params.contains_key("AWSAccessKeyId")
1072 && query_params.contains_key("Signature")
1073 && query_params.contains_key("Expires");
1074 if header_s3 || presigned_v4_s3 || presigned_v2 {
1075 return Some(("s3", ""));
1076 }
1077 }
1078
1079 None
1080}
1081
1082const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024 * 1024;
1092
1093pub fn max_request_body_bytes() -> usize {
1098 static CACHED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1099 *CACHED.get_or_init(|| {
1100 std::env::var("FAKECLOUD_MAX_REQUEST_BODY_BYTES")
1101 .ok()
1102 .and_then(|s| s.parse::<usize>().ok())
1103 .filter(|&n| n > 0)
1104 .unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES)
1105 })
1106}
1107
1108fn parse_account_from_arn(arn: &str) -> Option<String> {
1114 let mut parts = arn.splitn(6, ':');
1115 if parts.next()? != "arn" {
1116 return None;
1117 }
1118 let _partition = parts.next()?;
1119 let _service = parts.next()?;
1120 let _region = parts.next()?;
1121 let account = parts.next()?;
1122 parts.next()?;
1125 if account.is_empty() {
1126 None
1127 } else {
1128 Some(account.to_string())
1129 }
1130}
1131
1132fn user_agent_indicates_neptune(headers: &http::HeaderMap) -> bool {
1138 for name in ["user-agent", "x-amz-user-agent"] {
1139 if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
1140 for part in ua.split_whitespace() {
1141 if let Some(rest) = part.strip_prefix("api/neptune") {
1142 if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
1143 return true;
1144 }
1145 }
1146 }
1147 }
1148 }
1149 false
1150}
1151
1152fn user_agent_indicates_docdb(headers: &http::HeaderMap) -> bool {
1159 for name in ["user-agent", "x-amz-user-agent"] {
1160 if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
1161 for part in ua.split_whitespace() {
1162 if let Some(rest) = part.strip_prefix("api/docdb") {
1163 if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
1164 return true;
1165 }
1166 }
1167 }
1168 }
1169 }
1170 false
1171}
1172
1173fn extract_region_from_user_agent(headers: &http::HeaderMap) -> Option<String> {
1174 let ua = headers.get("user-agent")?.to_str().ok()?;
1175 for part in ua.split_whitespace() {
1176 if let Some(region) = part.strip_prefix("region/") {
1177 if !region.is_empty() {
1178 return Some(region.to_string());
1179 }
1180 }
1181 }
1182 None
1183}
1184
1185fn build_error_response(
1186 status: StatusCode,
1187 code: &str,
1188 message: &str,
1189 request_id: &str,
1190 protocol: AwsProtocol,
1191) -> Response<Body> {
1192 build_error_response_with_fields(status, code, message, request_id, protocol, &[])
1193}
1194
1195fn build_error_response_with_fields(
1196 status: StatusCode,
1197 code: &str,
1198 message: &str,
1199 request_id: &str,
1200 protocol: AwsProtocol,
1201 extra_fields: &[(String, String)],
1202) -> Response<Body> {
1203 let (status, content_type, body) = match protocol {
1204 AwsProtocol::Query => {
1207 fakecloud_aws::error::xml_error_response(status, code, message, request_id)
1208 }
1209 AwsProtocol::Ec2Query => {
1214 fakecloud_aws::ec2query::ec2_error_response(status, code, message, request_id)
1215 }
1216 AwsProtocol::Rest => fakecloud_aws::error::s3_xml_error_response_with_fields(
1217 status,
1218 code,
1219 message,
1220 request_id,
1221 extra_fields,
1222 ),
1223 AwsProtocol::Json | AwsProtocol::RestJson => {
1224 fakecloud_aws::error::json_error_response_with_fields(
1225 status,
1226 code,
1227 message,
1228 extra_fields,
1229 )
1230 }
1231 };
1232
1233 let safe_code = sanitize_header_value(code);
1243 let safe_message = sanitize_header_value(message);
1244 let mut builder = Response::builder()
1245 .status(status)
1246 .header("content-type", content_type)
1247 .header("x-amzn-requestid", request_id)
1248 .header("x-amz-request-id", request_id);
1249 if let Ok(v) = http::HeaderValue::from_str(&safe_code) {
1250 builder = builder.header("x-amz-error-code", v);
1251 }
1252 if let Ok(v) = http::HeaderValue::from_str(&safe_message) {
1253 builder = builder.header("x-amz-error-message", v);
1254 }
1255 builder.body(Body::from(body)).unwrap_or_else(|_| {
1256 Response::new(Body::empty())
1260 })
1261}
1262
1263fn sanitize_header_value(s: &str) -> String {
1268 const MAX_LEN: usize = 1024;
1269 let mut out = String::with_capacity(s.len().min(MAX_LEN));
1270 for ch in s.chars() {
1271 if out.len() >= MAX_LEN {
1272 break;
1273 }
1274 if ch.is_control() {
1277 if !out.ends_with(' ') {
1278 out.push(' ');
1279 }
1280 } else {
1281 out.push(ch);
1282 }
1283 }
1284 out.trim().to_string()
1285}
1286
1287fn sigv2_presigned_access_key(query_params: &HashMap<String, String>) -> Option<String> {
1307 if query_params.contains_key("Signature") && query_params.contains_key("Expires") {
1308 query_params.get("AWSAccessKeyId").cloned()
1309 } else {
1310 None
1311 }
1312}
1313
1314fn anonymous_s3_bucket(uri: &http::Uri, config: &DispatchConfig) -> Option<String> {
1315 let provider = config.resource_policy_provider.as_ref()?;
1316 let segment = uri.path().split('/').find(|s| !s.is_empty())?.to_string();
1317 let arn = format!("arn:aws:s3:::{segment}");
1318 provider.resource_owner_account("s3", &arn).map(|_| segment)
1319}
1320
1321fn build_condition_context(
1322 principal: &Principal,
1323 remote_addr: Option<SocketAddr>,
1324 region: &str,
1325 secure_transport: bool,
1326) -> ConditionContext {
1327 let now = chrono::Utc::now();
1328 ConditionContext {
1329 aws_username: aws_username_from_principal(principal),
1330 aws_userid: Some(principal.user_id.clone()),
1331 aws_principal_arn: Some(principal.arn.clone()),
1332 aws_principal_account: Some(principal.account_id.clone()),
1333 aws_principal_type: Some(principal_type_label(principal.principal_type).to_string()),
1334 aws_source_ip: remote_addr.map(|sa| sa.ip()),
1335 aws_current_time: Some(now),
1336 aws_epoch_time: Some(now.timestamp()),
1337 aws_secure_transport: Some(secure_transport),
1338 aws_requested_region: Some(region.to_string()),
1339 aws_mfa_present: None,
1345 aws_mfa_age_seconds: None,
1346 aws_called_via: Vec::new(),
1347 aws_source_vpce: None,
1348 aws_source_vpc: None,
1349 aws_vpc_source_ip: None,
1350 aws_federated_provider: None,
1351 aws_token_issue_time: None,
1352 service_keys: Default::default(),
1353 resource_tags: None,
1354 request_tags: None,
1355 principal_tags: None,
1356 }
1357}
1358
1359fn aws_username_from_principal(principal: &Principal) -> Option<String> {
1363 if principal.principal_type != PrincipalType::User {
1364 return None;
1365 }
1366 let after = principal.arn.rsplit_once(":user/").map(|(_, s)| s)?;
1367 Some(after.rsplit('/').next().unwrap_or(after).to_string())
1369}
1370
1371fn principal_type_label(t: PrincipalType) -> &'static str {
1374 match t {
1375 PrincipalType::User => "User",
1376 PrincipalType::AssumedRole => "AssumedRole",
1377 PrincipalType::FederatedUser => "FederatedUser",
1378 PrincipalType::Root => "Account",
1379 PrincipalType::Unknown => "Unknown",
1380 }
1381}
1382
1383fn is_secure_transport(headers: &http::HeaderMap) -> bool {
1389 headers
1390 .get("x-forwarded-proto")
1391 .and_then(|v| v.to_str().ok())
1392 .map(|s| s.eq_ignore_ascii_case("https"))
1393 .unwrap_or(false)
1394}
1395
1396trait ProtocolExt {
1397 fn error_status(&self) -> StatusCode;
1398}
1399
1400impl ProtocolExt for AwsProtocol {
1401 fn error_status(&self) -> StatusCode {
1402 StatusCode::BAD_REQUEST
1403 }
1404}
1405
1406#[cfg(test)]
1407mod tests {
1408 use super::*;
1409
1410 #[test]
1411 fn default_max_request_body_bytes_is_one_gib() {
1412 assert_eq!(DEFAULT_MAX_REQUEST_BODY_BYTES, 1024 * 1024 * 1024);
1416 }
1417
1418 #[test]
1419 fn sigv2_presigned_access_key_extracted_with_signature_and_expires() {
1420 let mut q = HashMap::new();
1421 q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
1422 q.insert("Signature".to_string(), "abc%2Bdef".to_string());
1423 q.insert("Expires".to_string(), "1700000000".to_string());
1424 assert_eq!(
1425 sigv2_presigned_access_key(&q).as_deref(),
1426 Some("AKIAEXAMPLE")
1427 );
1428 }
1429
1430 #[test]
1431 fn sigv2_presigned_access_key_none_without_signature_or_expires() {
1432 let mut q = HashMap::new();
1435 q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
1436 assert_eq!(sigv2_presigned_access_key(&q), None);
1437
1438 q.insert("Expires".to_string(), "1700000000".to_string());
1439 assert_eq!(
1440 sigv2_presigned_access_key(&q),
1441 None,
1442 "missing Signature must not qualify"
1443 );
1444 }
1445
1446 #[test]
1447 fn sigv2_presigned_access_key_none_for_unsigned_request() {
1448 assert_eq!(sigv2_presigned_access_key(&HashMap::new()), None);
1449 }
1450
1451 #[test]
1452 fn dispatch_config_new_defaults_to_off() {
1453 let cfg = DispatchConfig::new("us-east-1", "123456789012");
1454 assert_eq!(cfg.region, "us-east-1");
1455 assert_eq!(cfg.account_id, "123456789012");
1456 assert!(!cfg.verify_sigv4);
1457 assert_eq!(cfg.iam_mode, IamMode::Off);
1458 }
1459
1460 #[test]
1461 fn aws_username_strips_iam_path_for_users() {
1462 let p = Principal {
1463 arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
1464 user_id: "AIDAALICE".into(),
1465 account_id: "123456789012".into(),
1466 principal_type: PrincipalType::User,
1467 source_identity: None,
1468 tags: None,
1469 };
1470 assert_eq!(aws_username_from_principal(&p), Some("alice".into()));
1471 }
1472
1473 #[test]
1474 fn aws_username_unset_for_assumed_role() {
1475 let p = Principal {
1476 arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
1477 user_id: "AROAOPS:session".into(),
1478 account_id: "123456789012".into(),
1479 principal_type: PrincipalType::AssumedRole,
1480 source_identity: None,
1481 tags: None,
1482 };
1483 assert_eq!(aws_username_from_principal(&p), None);
1484 }
1485
1486 #[test]
1487 fn principal_type_label_matches_aws_casing() {
1488 assert_eq!(principal_type_label(PrincipalType::User), "User");
1489 assert_eq!(
1490 principal_type_label(PrincipalType::AssumedRole),
1491 "AssumedRole"
1492 );
1493 assert_eq!(principal_type_label(PrincipalType::Root), "Account");
1494 }
1495
1496 #[test]
1497 fn build_condition_context_populates_global_keys() {
1498 let p = Principal {
1499 arn: "arn:aws:iam::123456789012:user/alice".into(),
1500 user_id: "AIDAALICE".into(),
1501 account_id: "123456789012".into(),
1502 principal_type: PrincipalType::User,
1503 source_identity: None,
1504 tags: None,
1505 };
1506 let addr: SocketAddr = "10.0.0.1:54321".parse().unwrap();
1507 let ctx = build_condition_context(&p, Some(addr), "us-east-1", false);
1508 assert_eq!(ctx.aws_username.as_deref(), Some("alice"));
1509 assert_eq!(ctx.aws_userid.as_deref(), Some("AIDAALICE"));
1510 assert_eq!(
1511 ctx.aws_principal_arn.as_deref(),
1512 Some("arn:aws:iam::123456789012:user/alice")
1513 );
1514 assert_eq!(ctx.aws_principal_account.as_deref(), Some("123456789012"));
1515 assert_eq!(ctx.aws_principal_type.as_deref(), Some("User"));
1516 assert_eq!(
1517 ctx.aws_source_ip.map(|i| i.to_string()).as_deref(),
1518 Some("10.0.0.1")
1519 );
1520 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-east-1"));
1521 assert_eq!(ctx.aws_secure_transport, Some(false));
1522 assert!(ctx.aws_current_time.is_some());
1523 assert!(ctx.aws_epoch_time.is_some());
1524 }
1525
1526 #[test]
1527 fn is_secure_transport_reads_x_forwarded_proto() {
1528 let mut headers = http::HeaderMap::new();
1529 headers.insert("x-forwarded-proto", "https".parse().unwrap());
1530 assert!(is_secure_transport(&headers));
1531 headers.insert("x-forwarded-proto", "http".parse().unwrap());
1532 assert!(!is_secure_transport(&headers));
1533 let empty = http::HeaderMap::new();
1534 assert!(!is_secure_transport(&empty));
1535 }
1536
1537 #[test]
1538 fn parse_account_from_arn_extracts_standard_shapes() {
1539 assert_eq!(
1540 parse_account_from_arn("arn:aws:sqs:us-east-1:123456789012:queue"),
1541 Some("123456789012".to_string())
1542 );
1543 assert_eq!(
1544 parse_account_from_arn("arn:aws:iam::123456789012:user/alice"),
1545 Some("123456789012".to_string())
1546 );
1547 }
1548
1549 #[test]
1550 fn parse_account_from_arn_returns_none_for_s3_empty_account() {
1551 assert_eq!(parse_account_from_arn("arn:aws:s3:::my-bucket"), None);
1553 assert_eq!(
1554 parse_account_from_arn("arn:aws:s3:::my-bucket/path/to/key"),
1555 None
1556 );
1557 }
1558
1559 #[test]
1560 fn parse_account_from_arn_returns_none_for_malformed() {
1561 assert_eq!(parse_account_from_arn(""), None);
1562 assert_eq!(parse_account_from_arn("not-an-arn"), None);
1563 assert_eq!(parse_account_from_arn("arn:aws:sqs:us-east-1"), None);
1564 assert_eq!(parse_account_from_arn("arn:aws:sqs"), None);
1565 }
1566
1567 #[test]
1568 fn extract_region_from_user_agent_finds_region_segment() {
1569 let mut headers = http::HeaderMap::new();
1570 headers.insert(
1571 "user-agent",
1572 "aws-sdk-rust/1.0 os/linux region/eu-central-1"
1573 .parse()
1574 .unwrap(),
1575 );
1576 assert_eq!(
1577 extract_region_from_user_agent(&headers),
1578 Some("eu-central-1".to_string())
1579 );
1580 }
1581
1582 #[test]
1583 fn extract_region_from_user_agent_none_without_header() {
1584 let headers = http::HeaderMap::new();
1585 assert_eq!(extract_region_from_user_agent(&headers), None);
1586 }
1587
1588 #[test]
1589 fn extract_region_from_user_agent_ignores_empty_region() {
1590 let mut headers = http::HeaderMap::new();
1591 headers.insert("user-agent", "aws-sdk-java region/".parse().unwrap());
1592 assert_eq!(extract_region_from_user_agent(&headers), None);
1593 }
1594
1595 #[test]
1596 fn extract_region_from_user_agent_none_when_no_region_marker() {
1597 let mut headers = http::HeaderMap::new();
1598 headers.insert("user-agent", "curl/7.79.1".parse().unwrap());
1599 assert_eq!(extract_region_from_user_agent(&headers), None);
1600 }
1601
1602 #[test]
1603 fn aws_username_none_for_root() {
1604 let p = Principal {
1605 arn: "arn:aws:iam::123456789012:root".into(),
1606 user_id: "123456789012".into(),
1607 account_id: "123456789012".into(),
1608 principal_type: PrincipalType::Root,
1609 source_identity: None,
1610 tags: None,
1611 };
1612 assert_eq!(aws_username_from_principal(&p), None);
1613 }
1614
1615 #[test]
1616 fn aws_username_bare_no_path() {
1617 let p = Principal {
1618 arn: "arn:aws:iam::123456789012:user/bob".into(),
1619 user_id: "AIDABOB".into(),
1620 account_id: "123456789012".into(),
1621 principal_type: PrincipalType::User,
1622 source_identity: None,
1623 tags: None,
1624 };
1625 assert_eq!(aws_username_from_principal(&p), Some("bob".into()));
1626 }
1627
1628 #[test]
1629 fn principal_type_label_covers_federated_and_unknown() {
1630 assert_eq!(
1631 principal_type_label(PrincipalType::FederatedUser),
1632 "FederatedUser"
1633 );
1634 assert_eq!(principal_type_label(PrincipalType::Unknown), "Unknown");
1635 }
1636
1637 #[test]
1638 fn build_condition_context_marks_secure_when_flag_set() {
1639 let p = Principal {
1640 arn: "arn:aws:iam::123456789012:user/alice".into(),
1641 user_id: "AIDAALICE".into(),
1642 account_id: "123456789012".into(),
1643 principal_type: PrincipalType::User,
1644 source_identity: None,
1645 tags: None,
1646 };
1647 let ctx = build_condition_context(&p, None, "us-west-2", true);
1648 assert_eq!(ctx.aws_secure_transport, Some(true));
1649 assert!(ctx.aws_source_ip.is_none());
1650 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-west-2"));
1651 }
1652
1653 #[test]
1654 fn is_secure_transport_case_insensitive() {
1655 let mut headers = http::HeaderMap::new();
1656 headers.insert("x-forwarded-proto", "HTTPS".parse().unwrap());
1657 assert!(is_secure_transport(&headers));
1658 }
1659
1660 #[test]
1661 fn is_secure_transport_non_ascii_bytes_false() {
1662 let mut headers = http::HeaderMap::new();
1663 headers.insert(
1664 "x-forwarded-proto",
1665 http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
1666 );
1667 assert!(!is_secure_transport(&headers));
1668 }
1669
1670 #[test]
1671 fn protocol_ext_error_status_is_bad_request() {
1672 assert_eq!(AwsProtocol::Query.error_status(), StatusCode::BAD_REQUEST);
1673 assert_eq!(AwsProtocol::Json.error_status(), StatusCode::BAD_REQUEST);
1674 assert_eq!(AwsProtocol::Rest.error_status(), StatusCode::BAD_REQUEST);
1675 assert_eq!(
1676 AwsProtocol::RestJson.error_status(),
1677 StatusCode::BAD_REQUEST
1678 );
1679 }
1680
1681 #[test]
1682 fn build_error_response_json_has_json_content_type() {
1683 let resp = build_error_response(
1684 StatusCode::BAD_REQUEST,
1685 "TestCode",
1686 "test msg",
1687 "req-1",
1688 AwsProtocol::Json,
1689 );
1690 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1691 let ct = resp
1692 .headers()
1693 .get("content-type")
1694 .unwrap()
1695 .to_str()
1696 .unwrap();
1697 assert!(ct.contains("json"));
1698 let rid = resp
1699 .headers()
1700 .get("x-amzn-requestid")
1701 .unwrap()
1702 .to_str()
1703 .unwrap();
1704 assert_eq!(rid, "req-1");
1705 }
1706
1707 #[test]
1708 fn build_error_response_rest_returns_xml_content_type() {
1709 let resp = build_error_response(
1710 StatusCode::NOT_FOUND,
1711 "NoSuchBucket",
1712 "bucket missing",
1713 "req-2",
1714 AwsProtocol::Rest,
1715 );
1716 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1717 let ct = resp
1718 .headers()
1719 .get("content-type")
1720 .unwrap()
1721 .to_str()
1722 .unwrap();
1723 assert!(ct.contains("xml"));
1724 }
1725
1726 #[test]
1727 fn build_error_response_query_returns_xml() {
1728 let resp = build_error_response(
1729 StatusCode::BAD_REQUEST,
1730 "InvalidParameter",
1731 "bad param",
1732 "req-3",
1733 AwsProtocol::Query,
1734 );
1735 let ct = resp
1736 .headers()
1737 .get("content-type")
1738 .unwrap()
1739 .to_str()
1740 .unwrap();
1741 assert!(ct.contains("xml"));
1742 }
1743
1744 #[test]
1749 fn build_error_response_with_multiline_message_does_not_panic() {
1750 let resp = build_error_response(
1751 StatusCode::INTERNAL_SERVER_ERROR,
1752 "ServiceException",
1753 "Lambda execution failed: container failed to start: docker start failed: \
1754 Error: unable to start container \"abc\": \
1755 failed to create new hosts file:\nhost-gateway is empty\n",
1756 "req-multi",
1757 AwsProtocol::Json,
1758 );
1759 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
1760 let msg = resp
1761 .headers()
1762 .get("x-amz-error-message")
1763 .expect("x-amz-error-message must be set even when input contains newlines")
1764 .to_str()
1765 .unwrap();
1766 assert!(!msg.contains('\n'));
1767 assert!(!msg.contains('\r'));
1768 assert!(msg.contains("Lambda execution failed"));
1769 assert!(msg.contains("host-gateway is empty"));
1770 }
1771
1772 #[test]
1773 fn build_error_response_with_control_chars_strips_them() {
1774 let resp = build_error_response(
1775 StatusCode::BAD_REQUEST,
1776 "Code\twith\ttabs",
1777 "msg\x00with\x01nulls",
1778 "req-ctrl",
1779 AwsProtocol::Json,
1780 );
1781 let code = resp
1782 .headers()
1783 .get("x-amz-error-code")
1784 .unwrap()
1785 .to_str()
1786 .unwrap();
1787 let msg = resp
1788 .headers()
1789 .get("x-amz-error-message")
1790 .unwrap()
1791 .to_str()
1792 .unwrap();
1793 assert!(!code.contains('\t'));
1794 assert!(!msg.contains('\x00'));
1795 assert!(!msg.contains('\x01'));
1796 }
1797
1798 #[test]
1799 fn sanitize_header_value_truncates_long_input() {
1800 let huge = "x".repeat(5_000);
1801 let out = sanitize_header_value(&huge);
1802 assert!(out.len() <= 1024);
1803 }
1804
1805 #[test]
1806 fn sanitize_header_value_collapses_consecutive_control_runs() {
1807 let out = sanitize_header_value("a\n\n\n\rb");
1808 assert_eq!(out, "a b");
1809 }
1810
1811 #[test]
1812 fn dispatch_config_carries_opt_in_flags() {
1813 let cfg = DispatchConfig {
1814 region: "eu-west-1".to_string(),
1815 account_id: "000000000000".to_string(),
1816 verify_sigv4: true,
1817 iam_mode: IamMode::Strict,
1818 credential_resolver: None,
1819 policy_evaluator: None,
1820 resource_policy_provider: None,
1821 scp_resolver: None,
1822 };
1823 assert!(cfg.verify_sigv4);
1824 assert!(cfg.iam_mode.is_strict());
1825 assert!(cfg.resource_policy_provider.is_none());
1826 assert!(cfg.scp_resolver.is_none());
1827 }
1828
1829 fn s3_sigv4_headers() -> http::HeaderMap {
1830 let mut headers = http::HeaderMap::new();
1831 headers.insert(
1832 "authorization",
1833 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, \
1834 SignedHeaders=host, Signature=fake"
1835 .parse()
1836 .unwrap(),
1837 );
1838 headers
1839 }
1840
1841 #[test]
1842 fn streaming_route_path_style_s3_put_object() {
1843 let headers = s3_sigv4_headers();
1844 assert_eq!(
1845 streaming_route(
1846 &http::Method::PUT,
1847 "/my-bucket/key.txt",
1848 &headers,
1849 &HashMap::new(),
1850 ),
1851 Some(("s3", "")),
1852 );
1853 }
1854
1855 #[test]
1856 fn streaming_route_path_style_create_bucket_skipped() {
1857 let headers = s3_sigv4_headers();
1860 assert_eq!(
1861 streaming_route(&http::Method::PUT, "/my-bucket", &headers, &HashMap::new(),),
1862 None,
1863 );
1864 }
1865
1866 #[test]
1867 fn streaming_route_virtual_hosted_s3_put_object() {
1868 let mut headers = s3_sigv4_headers();
1869 headers.insert(
1870 "host",
1871 "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1872 .parse()
1873 .unwrap(),
1874 );
1875 assert_eq!(
1880 streaming_route(&http::Method::PUT, "/hello.txt", &headers, &HashMap::new(),),
1881 Some(("s3", "")),
1882 );
1883 }
1884
1885 #[test]
1886 fn streaming_route_virtual_hosted_s3_root_skipped() {
1887 let mut headers = s3_sigv4_headers();
1890 headers.insert(
1891 "host",
1892 "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
1893 .parse()
1894 .unwrap(),
1895 );
1896 assert_eq!(
1897 streaming_route(&http::Method::PUT, "/", &headers, &HashMap::new()),
1898 None,
1899 );
1900 }
1901
1902 #[test]
1903 fn streaming_route_ecr_blob_upload() {
1904 let headers = http::HeaderMap::new();
1905 assert_eq!(
1906 streaming_route(
1907 &http::Method::PATCH,
1908 "/v2/my-repo/blobs/uploads/abcd1234",
1909 &headers,
1910 &HashMap::new(),
1911 ),
1912 Some(("ecr", "")),
1913 );
1914 assert_eq!(
1915 streaming_route(
1916 &http::Method::PUT,
1917 "/v2/my-repo/blobs/uploads/abcd1234",
1918 &headers,
1919 &HashMap::new(),
1920 ),
1921 Some(("ecr", "")),
1922 );
1923 }
1924
1925 #[test]
1926 fn streaming_route_presigned_v4_s3_put() {
1927 let headers = http::HeaderMap::new();
1928 let mut query_params = HashMap::new();
1929 query_params.insert(
1930 "X-Amz-Credential".to_string(),
1931 "test/20240101/us-east-1/s3/aws4_request".to_string(),
1932 );
1933 assert_eq!(
1934 streaming_route(
1935 &http::Method::PUT,
1936 "/my-bucket/key.txt",
1937 &headers,
1938 &query_params,
1939 ),
1940 Some(("s3", "")),
1941 );
1942 }
1943
1944 #[test]
1945 fn streaming_route_non_s3_auth_header_skipped() {
1946 let mut headers = http::HeaderMap::new();
1949 headers.insert(
1950 "authorization",
1951 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/lambda/aws4_request, \
1952 SignedHeaders=host, Signature=fake"
1953 .parse()
1954 .unwrap(),
1955 );
1956 assert_eq!(
1957 streaming_route(
1958 &http::Method::PUT,
1959 "/my-bucket/key.txt",
1960 &headers,
1961 &HashMap::new(),
1962 ),
1963 None,
1964 );
1965 }
1966
1967 #[test]
1968 fn streaming_route_get_skipped() {
1969 let headers = s3_sigv4_headers();
1970 assert_eq!(
1971 streaming_route(
1972 &http::Method::GET,
1973 "/my-bucket/key.txt",
1974 &headers,
1975 &HashMap::new(),
1976 ),
1977 None,
1978 );
1979 }
1980}