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 match bedrock_agent_service_for(&parts.method, parts.uri.path()) {
168 Some(service) => protocol::DetectedRequest {
169 service: service.to_string(),
170 ..detected
171 },
172 None => detected,
173 }
174 } else {
175 detected
176 };
177
178 let detected = if detected.service == "rds" && user_agent_indicates_docdb(&parts.headers) {
187 protocol::DetectedRequest {
188 service: "docdb".to_string(),
189 ..detected
190 }
191 } else {
192 detected
193 };
194
195 let detected = if detected.service == "rds" && user_agent_indicates_neptune(&parts.headers) {
204 protocol::DetectedRequest {
205 service: "neptune".to_string(),
206 ..detected
207 }
208 } else {
209 detected
210 };
211
212 let service = match registry.get(&detected.service) {
214 Some(s) => s,
215 None => {
216 return build_error_response(
217 detected.protocol.error_status(),
218 "UnknownService",
219 &format!("Service '{}' is not available", detected.service),
220 &request_id,
221 detected.protocol,
222 );
223 }
224 };
225
226 let auth_header = parts
228 .headers
229 .get("authorization")
230 .and_then(|v| v.to_str().ok())
231 .unwrap_or("");
232 let header_info = fakecloud_aws::sigv4::parse_sigv4(auth_header);
233 let presigned_info = if header_info.is_none() {
234 fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params).map(|p| p.as_info())
236 } else {
237 None
238 };
239 let sigv4_info = header_info.or(presigned_info);
240 let access_key_id = sigv4_info
246 .as_ref()
247 .map(|info| info.access_key.clone())
248 .or_else(|| sigv2_presigned_access_key(&query_params));
249
250 let host_info = protocol::parse_routing_host_from_headers(&parts.headers);
256
257 let region = sigv4_info
258 .map(|info| info.region)
259 .or_else(|| host_info.as_ref().map(|h| h.region.clone()))
260 .or_else(|| extract_region_from_user_agent(&parts.headers))
261 .unwrap_or_else(|| config.region.clone());
262
263 let caller_akid = access_key_id.as_deref().unwrap_or("");
269 let resolved = if !caller_akid.is_empty() && !is_root_bypass(caller_akid) {
270 config
271 .credential_resolver
272 .as_ref()
273 .and_then(|r| r.resolve(caller_akid))
274 } else {
275 None
276 };
277 let caller_principal = resolved.as_ref().map(|r| r.principal.clone());
278 let caller_session_policies = resolved
279 .as_ref()
280 .map(|r| r.session_policies.clone())
281 .unwrap_or_default();
282
283 let is_fully_anonymous = auth_header.is_empty()
299 && !query_params.contains_key("X-Amz-Credential")
300 && sigv2_presigned_access_key(&query_params).is_none();
301 if config.verify_sigv4
302 && !is_fully_anonymous
303 && !is_root_bypass(caller_akid)
304 && config.credential_resolver.is_some()
305 {
306 let amz_date = parts
307 .headers
308 .get("x-amz-date")
309 .and_then(|v| v.to_str().ok());
310 let parsed = fakecloud_aws::sigv4::parse_sigv4_header(auth_header, amz_date)
311 .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params));
312 let parsed = match parsed {
313 Some(p) => p,
314 None => {
315 return build_error_response(
316 StatusCode::FORBIDDEN,
317 "IncompleteSignature",
318 "Request is missing or has a malformed AWS Signature",
319 &request_id,
320 detected.protocol,
321 );
322 }
323 };
324 let resolved_for_verify = match resolved.as_ref() {
325 Some(r) => r,
326 None => {
327 return build_error_response(
328 StatusCode::FORBIDDEN,
329 "InvalidClientTokenId",
330 "The security token included in the request is invalid",
331 &request_id,
332 detected.protocol,
333 );
334 }
335 };
336 let headers_vec = fakecloud_aws::sigv4::headers_from_http(&parts.headers);
337 let raw_query_for_verify = parts.uri.query().unwrap_or("").to_string();
338 let verify_req = fakecloud_aws::sigv4::VerifyRequest {
339 method: parts.method.as_str(),
340 path: parts.uri.path(),
341 query: &raw_query_for_verify,
342 headers: &headers_vec,
343 body: &body_bytes,
344 };
345 match fakecloud_aws::sigv4::verify(
346 &parsed,
347 &verify_req,
348 &resolved_for_verify.secret_access_key,
349 chrono::Utc::now(),
350 ) {
351 Ok(()) => {
352 if !parsed.is_presigned && detected.service != "s3" {
368 if let Some(signed_hash) = parts
369 .headers
370 .get("x-amz-content-sha256")
371 .and_then(|v| v.to_str().ok())
372 .filter(|h| is_hex_sha256(h))
373 {
374 if sha256_hex_lower(&body_bytes) != signed_hash {
375 return build_error_response(
376 StatusCode::FORBIDDEN,
377 "SignatureDoesNotMatch",
378 "The request signature we calculated does not match the signature you provided",
379 &request_id,
380 detected.protocol,
381 );
382 }
383 }
384 }
385 }
386 Err(fakecloud_aws::sigv4::SigV4Error::RequestTimeTooSkewed { .. }) => {
387 return build_error_response(
388 StatusCode::FORBIDDEN,
389 "RequestTimeTooSkewed",
390 "The difference between the request time and the current time is too large",
391 &request_id,
392 detected.protocol,
393 );
394 }
395 Err(fakecloud_aws::sigv4::SigV4Error::InvalidDate(msg)) => {
396 return build_error_response(
397 StatusCode::FORBIDDEN,
398 "IncompleteSignature",
399 &format!("Invalid x-amz-date: {msg}"),
400 &request_id,
401 detected.protocol,
402 );
403 }
404 Err(fakecloud_aws::sigv4::SigV4Error::Malformed(msg)) => {
405 return build_error_response(
406 StatusCode::FORBIDDEN,
407 "IncompleteSignature",
408 &format!("Malformed SigV4 signature: {msg}"),
409 &request_id,
410 detected.protocol,
411 );
412 }
413 Err(fakecloud_aws::sigv4::SigV4Error::SignatureMismatch) => {
414 return build_error_response(
415 StatusCode::FORBIDDEN,
416 "SignatureDoesNotMatch",
417 "The request signature we calculated does not match the signature you provided",
418 &request_id,
419 detected.protocol,
420 );
421 }
422 Err(fakecloud_aws::sigv4::SigV4Error::PresignedUrlExpired { .. }) => {
423 return build_error_response(
424 StatusCode::FORBIDDEN,
425 "AccessDenied",
426 "Request has expired",
427 &request_id,
428 detected.protocol,
429 );
430 }
431 Err(fakecloud_aws::sigv4::SigV4Error::InvalidPresignExpires(_)) => {
432 return build_error_response(
433 StatusCode::BAD_REQUEST,
434 "AuthorizationQueryParametersError",
435 "X-Amz-Expires must be a number between 1 and 604800 seconds",
436 &request_id,
437 detected.protocol,
438 );
439 }
440 }
441 }
442
443 let wire_path = parts.uri.path();
448 let path = if detected.service == "s3" {
449 s3_routing_path(
450 wire_path,
451 host_info.as_ref().and_then(|h| h.bucket.as_deref()),
452 )
453 } else {
454 wire_path.to_string()
455 };
456 let raw_query = parts.uri.query().unwrap_or("").to_string();
457 let path_segments: Vec<String> = path
458 .split('/')
459 .filter(|s| !s.is_empty())
460 .map(|s| s.to_string())
461 .collect();
462
463 if detected.protocol == AwsProtocol::Json
465 && !body_bytes.is_empty()
466 && serde_json::from_slice::<serde_json::Value>(&body_bytes).is_err()
467 {
468 return build_error_response(
469 StatusCode::BAD_REQUEST,
470 "SerializationException",
471 "Start of structure or map found where not expected",
472 &request_id,
473 AwsProtocol::Json,
474 );
475 }
476
477 let mut all_params = query_params;
480 if matches!(
481 detected.protocol,
482 AwsProtocol::Query | AwsProtocol::Ec2Query
483 ) {
484 let body_params = protocol::parse_query_body(&body_bytes);
485 for (k, v) in body_params {
486 all_params.entry(k).or_insert(v);
487 }
488 }
489
490 if detected.protocol == AwsProtocol::Json && detected.service == "monitoring" {
495 let body_params = protocol::flatten_json_to_query(&body_bytes);
496 for (k, v) in body_params {
497 all_params.entry(k).or_insert(v);
498 }
499 }
500
501 let aws_request = AwsRequest {
502 service: detected.service.clone(),
503 action: detected.action.clone(),
504 region,
505 account_id: caller_principal
506 .as_ref()
507 .map(|p| p.account_id.clone())
508 .unwrap_or_else(|| config.account_id.clone()),
509 request_id: request_id.clone(),
510 headers: parts.headers,
511 query_params: all_params,
512 body: body_bytes,
513 body_stream: parking_lot::Mutex::new(body_stream),
514 path_segments,
515 raw_path: path,
516 raw_query,
517 method: parts.method,
518 is_query_protocol: matches!(
519 detected.protocol,
520 AwsProtocol::Query | AwsProtocol::Ec2Query
521 ),
522 access_key_id,
523 principal: caller_principal,
524 };
525
526 tracing::info!(
527 service = %aws_request.service,
528 action = %aws_request.action,
529 request_id = %aws_request.request_id,
530 "handling request"
531 );
532
533 if config.iam_mode.is_enabled()
540 && service.iam_enforceable()
541 && !is_root_bypass(aws_request.access_key_id.as_deref().unwrap_or(""))
542 {
543 if let Some(evaluator) = config.policy_evaluator.as_ref() {
544 if let Some(principal) = aws_request.principal.as_ref() {
545 if !principal.is_root() {
546 let iam_actions = service.iam_actions_for(&aws_request);
549 if !iam_actions.is_empty() {
550 for iam_action in &iam_actions {
551 let mut condition_context = build_condition_context(
552 principal,
553 remote_addr,
554 &aws_request.region,
555 is_secure_transport(&aws_request.headers),
556 );
557 if let Some(rc) = resolved.as_ref() {
565 condition_context.aws_mfa_present = Some(rc.mfa_present);
566 condition_context.aws_token_issue_time = rc.token_issued_at;
567 condition_context.aws_federated_provider =
568 rc.federated_provider.clone();
569 if rc.mfa_present {
577 if let Some(issued) = rc.token_issued_at {
578 let age = chrono::Utc::now()
579 .signed_duration_since(issued)
580 .num_seconds()
581 .max(0);
582 condition_context.aws_mfa_age_seconds = Some(age);
583 }
584 }
585 }
586 condition_context.service_keys =
587 service.iam_condition_keys_for(&aws_request, iam_action);
588
589 match service.resource_tags_for(&iam_action.resource) {
592 Some(tags) => condition_context.resource_tags = Some(tags),
593 None => tracing::debug!(
594 target: "fakecloud::iam::audit",
595 service = %detected.service,
596 resource = %iam_action.resource,
597 "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation"
598 ),
599 }
600 match service.request_tags_from(&aws_request, iam_action.action) {
602 Some(tags) => condition_context.request_tags = Some(tags),
603 None => tracing::debug!(
604 target: "fakecloud::iam::audit",
605 service = %detected.service,
606 action = %iam_action.action_string(),
607 "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation"
608 ),
609 }
610 condition_context.principal_tags = principal.tags.clone();
612
613 let resource_policy_json =
622 config.resource_policy_provider.as_ref().and_then(|p| {
623 p.resource_policy(&detected.service, &iam_action.resource)
624 });
625 let resource_account_id = config
635 .resource_policy_provider
636 .as_ref()
637 .and_then(|p| {
638 p.resource_owner_account(
639 &detected.service,
640 &iam_action.resource,
641 )
642 })
643 .or_else(|| parse_account_from_arn(&iam_action.resource))
644 .unwrap_or_else(|| principal.account_id.clone());
645 let scps = config
652 .scp_resolver
653 .as_ref()
654 .and_then(|r| r.scps_for(principal));
655 let decision = evaluator.evaluate_with_resource_policy(
656 principal,
657 iam_action,
658 &condition_context,
659 resource_policy_json.as_deref(),
660 &resource_account_id,
661 &caller_session_policies,
662 scps.as_deref(),
663 );
664 if !decision.is_allow() {
665 tracing::warn!(
666 target: "fakecloud::iam::audit",
667 service = %detected.service,
668 action = %iam_action.action_string(),
669 resource = %iam_action.resource,
670 principal = %principal.arn,
671 resource_policy_present = resource_policy_json.is_some(),
672 decision = ?decision,
673 mode = %config.iam_mode,
674 request_id = %request_id,
675 "IAM policy evaluation denied request"
676 );
677 if config.iam_mode.is_strict() {
678 let context_summary = serde_json::json!({
691 "aws:PrincipalArn": principal.arn,
692 "aws:PrincipalAccount": principal.account_id,
693 "aws:RequestedRegion": condition_context
694 .aws_requested_region
695 .clone()
696 .unwrap_or_default(),
697 "aws:SecureTransport": condition_context
698 .aws_secure_transport
699 .unwrap_or(false),
700 "aws:Action": iam_action.action_string(),
701 "aws:Resource": iam_action.resource,
702 "decision": format!("{:?}", decision),
703 });
704 let action_string = iam_action.action_string();
705 let encoded = crate::auth_message::encode_deny(
706 matches!(decision, crate::auth::IamDecision::ExplicitDeny),
707 Some(&action_string),
708 Some(&principal.arn),
709 Vec::new(),
710 Some(context_summary),
711 );
712 return build_error_response(
713 StatusCode::FORBIDDEN,
714 "AccessDeniedException",
715 &format!(
716 "User: {} is not authorized to perform: {} on resource: {} Encoded authorization failure message: {}",
717 principal.arn,
718 iam_action.action_string(),
719 iam_action.resource,
720 encoded,
721 ),
722 &request_id,
723 detected.protocol,
724 );
725 }
726 }
729 }
730 } else {
731 tracing::warn!(
743 target: "fakecloud::iam::audit",
744 service = %detected.service,
745 action = %aws_request.action,
746 mode = %config.iam_mode,
747 request_id = %request_id,
748 "service is iam_enforceable but has no IamAction mapping for this action; denying under strict, allowing under soft"
749 );
750 if config.iam_mode.is_strict() {
751 return build_error_response(
752 StatusCode::FORBIDDEN,
753 "AccessDeniedException",
754 &format!(
755 "User: {} is not authorized to perform: {}: no IAM action mapping exists for this operation, so it cannot be authorized under strict IAM enforcement",
756 principal.arn, aws_request.action,
757 ),
758 &request_id,
759 detected.protocol,
760 );
761 }
762 }
765 }
766 } else if aws_request.access_key_id.is_none() {
767 let iam_actions = service.iam_actions_for(&aws_request);
783 if !iam_actions.is_empty() {
784 for iam_action in &iam_actions {
785 let now = chrono::Utc::now();
786 let mut condition_context = ConditionContext {
787 aws_source_ip: remote_addr.map(|sa| sa.ip()),
788 aws_current_time: Some(now),
789 aws_epoch_time: Some(now.timestamp()),
790 aws_secure_transport: Some(is_secure_transport(&aws_request.headers)),
791 aws_requested_region: Some(aws_request.region.clone()),
792 ..Default::default()
793 };
794 condition_context.service_keys =
795 service.iam_condition_keys_for(&aws_request, iam_action);
796 let resource_policy_json =
797 config.resource_policy_provider.as_ref().and_then(|p| {
798 p.resource_policy(&detected.service, &iam_action.resource)
799 });
800 let policy_decision = evaluator.evaluate_anonymous(
801 iam_action,
802 &condition_context,
803 resource_policy_json.as_deref(),
804 );
805 let policy_allows = policy_decision.is_allow();
806 let policy_explicit_deny =
811 matches!(policy_decision, crate::auth::IamDecision::ExplicitDeny);
812 let acl_allows = !policy_explicit_deny
813 && config.resource_policy_provider.as_ref().is_some_and(|p| {
814 p.public_acl_allows(
815 &detected.service,
816 &iam_action.resource,
817 iam_action.action,
818 )
819 });
820 if !policy_allows && !acl_allows {
821 tracing::warn!(
822 target: "fakecloud::iam::audit",
823 service = %detected.service,
824 action = %iam_action.action_string(),
825 resource = %iam_action.resource,
826 resource_policy_present = resource_policy_json.is_some(),
827 mode = %config.iam_mode,
828 request_id = %request_id,
829 "anonymous request denied: no public bucket policy or ACL grants the action"
830 );
831 if config.iam_mode.is_strict() {
832 return build_error_response(
833 StatusCode::FORBIDDEN,
834 "AccessDenied",
835 "Access Denied",
836 &request_id,
837 detected.protocol,
838 );
839 }
840 }
842 }
843 } else {
844 tracing::warn!(
851 target: "fakecloud::iam::audit",
852 service = %detected.service,
853 action = %aws_request.action,
854 mode = %config.iam_mode,
855 request_id = %request_id,
856 "anonymous request to iam_enforceable service has no IamAction mapping; denying under strict, allowing under soft"
857 );
858 if config.iam_mode.is_strict() {
859 return build_error_response(
860 StatusCode::FORBIDDEN,
861 "AccessDenied",
862 "Access Denied",
863 &request_id,
864 detected.protocol,
865 );
866 }
867 }
868 }
869 }
870 }
871
872 match service.handle(aws_request).await {
873 Ok(resp) => {
874 let mut builder = Response::builder()
875 .status(resp.status)
876 .header("x-amzn-requestid", &request_id)
877 .header("x-amz-request-id", &request_id);
878
879 if !resp.content_type.is_empty() {
880 builder = builder.header("content-type", &resp.content_type);
881 }
882
883 let has_content_length = resp
884 .headers
885 .iter()
886 .any(|(k, _)| k.as_str().eq_ignore_ascii_case("content-length"));
887
888 for (k, v) in &resp.headers {
889 builder = builder.header(k, v);
890 }
891
892 match resp.body {
893 ResponseBody::Bytes(b) => builder.body(Body::from(b)).unwrap(),
894 ResponseBody::File { file, size } => {
895 let stream = tokio_util::io::ReaderStream::new(file);
896 let body = Body::from_stream(stream);
897 if !has_content_length {
898 builder = builder.header("content-length", size.to_string());
899 }
900 builder.body(body).unwrap()
901 }
902 }
903 }
904 Err(err) => {
905 tracing::warn!(
906 service = %detected.service,
907 action = %detected.action,
908 error = %err,
909 "request failed"
910 );
911 let error_headers = err.response_headers().to_vec();
912 let mut resp = build_error_response_with_fields(
913 err.status(),
914 err.code(),
915 &err.message(),
916 &request_id,
917 detected.protocol,
918 err.extra_fields(),
919 );
920 for (k, v) in &error_headers {
921 if let (Ok(name), Ok(val)) = (
922 k.parse::<http::header::HeaderName>(),
923 v.parse::<http::header::HeaderValue>(),
924 ) {
925 if name == http::header::VARY {
931 let already = resp
932 .headers()
933 .get_all(&name)
934 .iter()
935 .any(|existing| existing == val);
936 if !already {
937 resp.headers_mut().append(name, val);
938 }
939 } else {
940 resp.headers_mut().insert(name, val);
941 }
942 }
943 }
944 resp
945 }
946 }
947}
948
949#[derive(Clone)]
951pub struct DispatchConfig {
952 pub region: String,
953 pub account_id: String,
954 pub verify_sigv4: bool,
958 pub iam_mode: IamMode,
963 pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
967 pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
971 pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
978 pub scp_resolver: Option<Arc<dyn crate::auth::ScpResolver>>,
985}
986
987impl std::fmt::Debug for DispatchConfig {
988 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
989 f.debug_struct("DispatchConfig")
990 .field("region", &self.region)
991 .field("account_id", &self.account_id)
992 .field("verify_sigv4", &self.verify_sigv4)
993 .field("iam_mode", &self.iam_mode)
994 .field(
995 "credential_resolver",
996 &self
997 .credential_resolver
998 .as_ref()
999 .map(|_| "<CredentialResolver>"),
1000 )
1001 .field(
1002 "policy_evaluator",
1003 &self
1004 .policy_evaluator
1005 .as_ref()
1006 .map(|_| "<IamPolicyEvaluator>"),
1007 )
1008 .field(
1009 "resource_policy_provider",
1010 &self
1011 .resource_policy_provider
1012 .as_ref()
1013 .map(|_| "<ResourcePolicyProvider>"),
1014 )
1015 .field(
1016 "scp_resolver",
1017 &self.scp_resolver.as_ref().map(|_| "<ScpResolver>"),
1018 )
1019 .finish()
1020 }
1021}
1022
1023impl DispatchConfig {
1024 pub fn new(region: impl Into<String>, account_id: impl Into<String>) -> Self {
1027 Self {
1028 region: region.into(),
1029 account_id: account_id.into(),
1030 verify_sigv4: false,
1031 iam_mode: IamMode::Off,
1032 credential_resolver: None,
1033 policy_evaluator: None,
1034 resource_policy_provider: None,
1035 scp_resolver: None,
1036 }
1037 }
1038}
1039
1040fn s3_routing_path(wire_path: &str, host_bucket: Option<&str>) -> String {
1056 let Some(bucket) = host_bucket else {
1057 return wire_path.to_string();
1058 };
1059 if wire_path == "/" || wire_path.is_empty() {
1060 format!("/{bucket}")
1061 } else {
1062 format!("/{bucket}{wire_path}")
1063 }
1064}
1065
1066fn streaming_route(
1086 method: &http::Method,
1087 path: &str,
1088 headers: &http::HeaderMap,
1089 query_params: &HashMap<String, String>,
1090) -> Option<(&'static str, &'static str)> {
1091 if (method == http::Method::PATCH || method == http::Method::PUT)
1093 && path.starts_with("/v2/")
1094 && path.contains("/blobs/uploads/")
1095 {
1096 return Some(("ecr", ""));
1097 }
1098
1099 if method == http::Method::PUT {
1104 let host_bucket = protocol::parse_routing_host_from_headers(headers)
1116 .filter(|h| h.service == "s3")
1117 .and_then(|h| h.bucket);
1118 let routed = s3_routing_path(path, host_bucket.as_deref());
1119 let has_key = routed.split('/').filter(|seg| !seg.is_empty()).count() >= 2;
1120 if !has_key {
1121 return None;
1122 }
1123 let header_s3 = headers
1124 .get("authorization")
1125 .and_then(|v| v.to_str().ok())
1126 .and_then(fakecloud_aws::sigv4::parse_sigv4)
1127 .map(|info| info.service == "s3")
1128 .unwrap_or(false);
1129 let presigned_v4_s3 = query_params
1130 .get("X-Amz-Credential")
1131 .and_then(|c| c.split('/').nth(3).map(|s| s.to_string()))
1132 .map(|service| service == "s3")
1133 .unwrap_or(false);
1134 let presigned_v2 = query_params.contains_key("AWSAccessKeyId")
1135 && query_params.contains_key("Signature")
1136 && query_params.contains_key("Expires");
1137 if header_s3 || presigned_v4_s3 || presigned_v2 {
1138 return Some(("s3", ""));
1139 }
1140 }
1141
1142 None
1143}
1144
1145const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024 * 1024;
1155
1156pub fn max_request_body_bytes() -> usize {
1161 static CACHED: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
1162 *CACHED.get_or_init(|| {
1163 std::env::var("FAKECLOUD_MAX_REQUEST_BODY_BYTES")
1164 .ok()
1165 .and_then(|s| s.parse::<usize>().ok())
1166 .filter(|&n| n > 0)
1167 .unwrap_or(DEFAULT_MAX_REQUEST_BODY_BYTES)
1168 })
1169}
1170
1171fn parse_account_from_arn(arn: &str) -> Option<String> {
1177 let mut parts = arn.splitn(6, ':');
1178 if parts.next()? != "arn" {
1179 return None;
1180 }
1181 let _partition = parts.next()?;
1182 let _service = parts.next()?;
1183 let _region = parts.next()?;
1184 let account = parts.next()?;
1185 parts.next()?;
1188 if account.is_empty() {
1189 None
1190 } else {
1191 Some(account.to_string())
1192 }
1193}
1194
1195fn user_agent_indicates_neptune(headers: &http::HeaderMap) -> bool {
1201 for name in ["user-agent", "x-amz-user-agent"] {
1202 if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
1203 for part in ua.split_whitespace() {
1204 if let Some(rest) = part.strip_prefix("api/neptune") {
1205 if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
1206 return true;
1207 }
1208 }
1209 }
1210 }
1211 }
1212 false
1213}
1214
1215fn user_agent_indicates_docdb(headers: &http::HeaderMap) -> bool {
1222 for name in ["user-agent", "x-amz-user-agent"] {
1223 if let Some(ua) = headers.get(name).and_then(|v| v.to_str().ok()) {
1224 for part in ua.split_whitespace() {
1225 if let Some(rest) = part.strip_prefix("api/docdb") {
1226 if rest.is_empty() || rest.starts_with('#') || rest.starts_with('/') {
1227 return true;
1228 }
1229 }
1230 }
1231 }
1232 }
1233 false
1234}
1235
1236fn extract_region_from_user_agent(headers: &http::HeaderMap) -> Option<String> {
1237 let ua = headers.get("user-agent")?.to_str().ok()?;
1238 for part in ua.split_whitespace() {
1239 if let Some(region) = part.strip_prefix("region/") {
1240 if !region.is_empty() {
1241 return Some(region.to_string());
1242 }
1243 }
1244 }
1245 None
1246}
1247
1248fn build_error_response(
1249 status: StatusCode,
1250 code: &str,
1251 message: &str,
1252 request_id: &str,
1253 protocol: AwsProtocol,
1254) -> Response<Body> {
1255 build_error_response_with_fields(status, code, message, request_id, protocol, &[])
1256}
1257
1258fn build_error_response_with_fields(
1259 status: StatusCode,
1260 code: &str,
1261 message: &str,
1262 request_id: &str,
1263 protocol: AwsProtocol,
1264 extra_fields: &[(String, String)],
1265) -> Response<Body> {
1266 let (status, content_type, body) = match protocol {
1267 AwsProtocol::Query => {
1270 fakecloud_aws::error::xml_error_response(status, code, message, request_id)
1271 }
1272 AwsProtocol::Ec2Query => {
1277 fakecloud_aws::ec2query::ec2_error_response(status, code, message, request_id)
1278 }
1279 AwsProtocol::Rest => fakecloud_aws::error::s3_xml_error_response_with_fields(
1280 status,
1281 code,
1282 message,
1283 request_id,
1284 extra_fields,
1285 ),
1286 AwsProtocol::Json | AwsProtocol::RestJson => {
1287 fakecloud_aws::error::json_error_response_with_fields(
1288 status,
1289 code,
1290 message,
1291 extra_fields,
1292 )
1293 }
1294 };
1295
1296 let safe_code = sanitize_header_value(code);
1306 let safe_message = sanitize_header_value(message);
1307 let mut builder = Response::builder()
1308 .status(status)
1309 .header("content-type", content_type)
1310 .header("x-amzn-requestid", request_id)
1311 .header("x-amz-request-id", request_id);
1312 if let Ok(v) = http::HeaderValue::from_str(&safe_code) {
1313 builder = builder.header("x-amz-error-code", v);
1314 }
1315 if let Ok(v) = http::HeaderValue::from_str(&safe_message) {
1316 builder = builder.header("x-amz-error-message", v);
1317 }
1318 builder.body(Body::from(body)).unwrap_or_else(|_| {
1319 Response::new(Body::empty())
1323 })
1324}
1325
1326fn sanitize_header_value(s: &str) -> String {
1331 const MAX_LEN: usize = 1024;
1332 let mut out = String::with_capacity(s.len().min(MAX_LEN));
1333 for ch in s.chars() {
1334 if out.len() >= MAX_LEN {
1335 break;
1336 }
1337 if ch.is_control() {
1340 if !out.ends_with(' ') {
1341 out.push(' ');
1342 }
1343 } else {
1344 out.push(ch);
1345 }
1346 }
1347 out.trim().to_string()
1348}
1349
1350fn sigv2_presigned_access_key(query_params: &HashMap<String, String>) -> Option<String> {
1370 if query_params.contains_key("Signature") && query_params.contains_key("Expires") {
1371 query_params.get("AWSAccessKeyId").cloned()
1372 } else {
1373 None
1374 }
1375}
1376
1377fn is_hex_sha256(s: &str) -> bool {
1383 s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
1384}
1385
1386fn sha256_hex_lower(bytes: &[u8]) -> String {
1389 use sha2::{Digest, Sha256};
1390 let digest = Sha256::digest(bytes);
1391 const HEX: &[u8] = b"0123456789abcdef";
1392 let mut out = String::with_capacity(64);
1393 for b in digest {
1394 out.push(HEX[(b >> 4) as usize] as char);
1395 out.push(HEX[(b & 0x0f) as usize] as char);
1396 }
1397 out
1398}
1399
1400fn anonymous_s3_bucket(uri: &http::Uri, config: &DispatchConfig) -> Option<String> {
1401 let provider = config.resource_policy_provider.as_ref()?;
1402 let segment = uri.path().split('/').find(|s| !s.is_empty())?.to_string();
1403 let arn = fakecloud_aws::arn::Arn::s3(&segment).to_string();
1404 provider.resource_owner_account("s3", &arn).map(|_| segment)
1405}
1406
1407fn build_condition_context(
1408 principal: &Principal,
1409 remote_addr: Option<SocketAddr>,
1410 region: &str,
1411 secure_transport: bool,
1412) -> ConditionContext {
1413 let now = chrono::Utc::now();
1414 ConditionContext {
1415 aws_username: aws_username_from_principal(principal),
1416 aws_userid: Some(principal.user_id.clone()),
1417 aws_principal_arn: Some(principal.arn.clone()),
1418 aws_principal_account: Some(principal.account_id.clone()),
1419 aws_principal_type: Some(principal_type_label(principal.principal_type).to_string()),
1420 aws_source_ip: remote_addr.map(|sa| sa.ip()),
1421 aws_current_time: Some(now),
1422 aws_epoch_time: Some(now.timestamp()),
1423 aws_secure_transport: Some(secure_transport),
1424 aws_requested_region: Some(region.to_string()),
1425 aws_mfa_present: None,
1431 aws_mfa_age_seconds: None,
1432 aws_called_via: Vec::new(),
1433 aws_source_vpce: None,
1434 aws_source_vpc: None,
1435 aws_vpc_source_ip: None,
1436 aws_federated_provider: None,
1437 aws_token_issue_time: None,
1438 service_keys: Default::default(),
1439 resource_tags: None,
1440 request_tags: None,
1441 principal_tags: None,
1442 }
1443}
1444
1445fn aws_username_from_principal(principal: &Principal) -> Option<String> {
1449 if principal.principal_type != PrincipalType::User {
1450 return None;
1451 }
1452 let after = principal.arn.rsplit_once(":user/").map(|(_, s)| s)?;
1453 Some(after.rsplit('/').next().unwrap_or(after).to_string())
1455}
1456
1457fn principal_type_label(t: PrincipalType) -> &'static str {
1460 match t {
1461 PrincipalType::User => "User",
1462 PrincipalType::AssumedRole => "AssumedRole",
1463 PrincipalType::FederatedUser => "FederatedUser",
1464 PrincipalType::Root => "Account",
1465 PrincipalType::Unknown => "Unknown",
1466 }
1467}
1468
1469fn is_secure_transport(headers: &http::HeaderMap) -> bool {
1475 headers
1476 .get("x-forwarded-proto")
1477 .and_then(|v| v.to_str().ok())
1478 .map(|s| s.eq_ignore_ascii_case("https"))
1479 .unwrap_or(false)
1480}
1481
1482trait ProtocolExt {
1483 fn error_status(&self) -> StatusCode;
1484}
1485
1486impl ProtocolExt for AwsProtocol {
1487 fn error_status(&self) -> StatusCode {
1488 StatusCode::BAD_REQUEST
1489 }
1490}
1491
1492fn names_bedrock_session(label: &str) -> bool {
1495 let decoded = label
1496 .to_ascii_lowercase()
1497 .replace("%3a", ":")
1498 .replace("%2f", "/");
1499 decoded.starts_with("arn:") && decoded.contains(":bedrock:") && decoded.contains(":session/")
1500}
1501
1502fn bedrock_agent_service_for(method: &http::Method, path: &str) -> Option<&'static str> {
1508 let first_seg = path.split('/').nth(1);
1509 if !matches!(
1510 first_seg,
1511 Some(
1512 "agents"
1513 | "knowledgebases"
1514 | "flows"
1515 | "prompts"
1516 | "tags"
1517 | "retrieveAndGenerate"
1518 | "retrieveAndGenerateStream"
1519 | "optimize-prompt"
1520 | "sessions"
1521 | "invocations"
1522 | "generate-query"
1523 | "rerank"
1524 )
1525 ) {
1526 return None;
1527 }
1528 let segs: Vec<&str> = path.split('/').collect();
1529 let is_runtime = matches!(
1530 segs.as_slice(),
1531 ["", "agents", _, "agentAliases", _, ..] | ["", "flows", _, "executions"] | ["", "flows", _, "aliases", _, "executions", ..] | ["", "knowledgebases", _, "retrieve"] | ["", "retrieveAndGenerate"]
1536 | ["", "retrieveAndGenerateStream"]
1537 | ["", "optimize-prompt"]
1538 | ["", "sessions", ..]
1539 | ["", "invocations", ..]
1540 | ["", "generate-query"]
1541 | ["", "rerank"]
1542 ) || (
1543 *method == http::Method::POST && matches!(segs.as_slice(), ["", "flows", _, "aliases", _])
1546 ) || (
1547 *method == http::Method::POST
1550 && matches!(segs.as_slice(), ["", "agents", id] if !id.is_empty())
1551 ) || path
1552 .strip_prefix("/tags/")
1556 .is_some_and(names_bedrock_session);
1557 Some(if is_runtime {
1558 "bedrock-agent-runtime"
1559 } else {
1560 "bedrock-agent"
1561 })
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566 #[test]
1567 fn bedrock_agent_paths_split_between_runtime_and_control_plane() {
1568 use http::Method;
1569 let runtime = Some("bedrock-agent-runtime");
1570 let agent = Some("bedrock-agent");
1571 for (method, path, want) in [
1572 (Method::POST, "/flows/F/aliases/A", runtime),
1573 (Method::GET, "/flows/F/aliases/A", agent),
1574 (Method::PUT, "/flows/F/aliases/A", agent),
1575 (Method::DELETE, "/flows/F/aliases/A", agent),
1576 (Method::POST, "/flows/F/aliases/A/executions", runtime),
1577 (Method::GET, "/flows/F/aliases/A/executions/E", runtime),
1578 (
1579 Method::POST,
1580 "/flows/F/aliases/A/executions/E/stop",
1581 runtime,
1582 ),
1583 (
1584 Method::GET,
1585 "/flows/F/aliases/A/executions/E/events",
1586 runtime,
1587 ),
1588 (
1589 Method::GET,
1590 "/flows/F/aliases/A/executions/E/flowsnapshot",
1591 runtime,
1592 ),
1593 (Method::GET, "/flows/F/executions", runtime),
1594 (Method::GET, "/flows/F/aliases", agent),
1595 (Method::POST, "/flows/F/versions", agent),
1596 (Method::GET, "/flows/F", agent),
1597 (
1598 Method::POST,
1599 "/agents/X/agentAliases/Y/sessions/S/text",
1600 runtime,
1601 ),
1602 (Method::GET, "/agents/X", agent),
1603 (Method::POST, "/agents/session-1", runtime),
1604 (Method::POST, "/agents/X/", agent),
1605 (Method::GET, "/agents/X/", agent),
1606 (Method::PUT, "/agents/X/", agent),
1607 (
1608 Method::POST,
1609 "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Asession%2F0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
1610 runtime,
1611 ),
1612 (
1613 Method::GET,
1614 "/tags/arn:aws:bedrock:us-east-1:123456789012:session/0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b",
1615 runtime,
1616 ),
1617 (
1618 Method::DELETE,
1619 "/tags/arn%3aaws%3abedrock%3aus-east-1%3a123456789012%3asession%2fabc",
1620 runtime,
1621 ),
1622 (
1623 Method::POST,
1624 "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Aagent%2FAGENT12345",
1625 agent,
1626 ),
1627 (
1628 Method::GET,
1629 "/tags/arn%3Aaws%3Abedrock%3Aus-east-1%3A123456789012%3Aflow%2FFLOW123456",
1630 agent,
1631 ),
1632 (Method::POST, "/model/m/invoke", None),
1633 ] {
1634 assert_eq!(
1635 bedrock_agent_service_for(&method, path),
1636 want,
1637 "{method} {path}"
1638 );
1639 }
1640 }
1641
1642 use super::*;
1643
1644 #[test]
1645 fn default_max_request_body_bytes_is_one_gib() {
1646 assert_eq!(DEFAULT_MAX_REQUEST_BODY_BYTES, 1024 * 1024 * 1024);
1650 }
1651
1652 #[test]
1653 fn sigv2_presigned_access_key_extracted_with_signature_and_expires() {
1654 let mut q = HashMap::new();
1655 q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
1656 q.insert("Signature".to_string(), "abc%2Bdef".to_string());
1657 q.insert("Expires".to_string(), "1700000000".to_string());
1658 assert_eq!(
1659 sigv2_presigned_access_key(&q).as_deref(),
1660 Some("AKIAEXAMPLE")
1661 );
1662 }
1663
1664 #[test]
1665 fn sigv2_presigned_access_key_none_without_signature_or_expires() {
1666 let mut q = HashMap::new();
1669 q.insert("AWSAccessKeyId".to_string(), "AKIAEXAMPLE".to_string());
1670 assert_eq!(sigv2_presigned_access_key(&q), None);
1671
1672 q.insert("Expires".to_string(), "1700000000".to_string());
1673 assert_eq!(
1674 sigv2_presigned_access_key(&q),
1675 None,
1676 "missing Signature must not qualify"
1677 );
1678 }
1679
1680 #[test]
1681 fn sigv2_presigned_access_key_none_for_unsigned_request() {
1682 assert_eq!(sigv2_presigned_access_key(&HashMap::new()), None);
1683 }
1684
1685 #[test]
1686 fn is_hex_sha256_accepts_real_digest_rejects_markers() {
1687 assert!(is_hex_sha256(&sha256_hex_lower(b"hello")));
1689 assert!(is_hex_sha256(
1690 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1691 ));
1692 assert!(!is_hex_sha256("UNSIGNED-PAYLOAD"));
1694 assert!(!is_hex_sha256("STREAMING-AWS4-HMAC-SHA256-PAYLOAD"));
1695 assert!(!is_hex_sha256("STREAMING-UNSIGNED-PAYLOAD-TRAILER"));
1696 assert!(!is_hex_sha256("abc123"));
1698 assert!(!is_hex_sha256(
1699 "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855"
1700 ));
1701 }
1702
1703 #[test]
1704 fn sha256_hex_lower_matches_known_vectors() {
1705 assert_eq!(
1707 sha256_hex_lower(b""),
1708 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1709 );
1710 assert_eq!(
1711 sha256_hex_lower(b"abc"),
1712 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1713 );
1714 assert_eq!(sha256_hex_lower(b"abc").len(), 64);
1715 }
1716
1717 #[test]
1718 fn dispatch_config_new_defaults_to_off() {
1719 let cfg = DispatchConfig::new("us-east-1", "123456789012");
1720 assert_eq!(cfg.region, "us-east-1");
1721 assert_eq!(cfg.account_id, "123456789012");
1722 assert!(!cfg.verify_sigv4);
1723 assert_eq!(cfg.iam_mode, IamMode::Off);
1724 }
1725
1726 #[test]
1727 fn aws_username_strips_iam_path_for_users() {
1728 let p = Principal {
1729 arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
1730 user_id: "AIDAALICE".into(),
1731 account_id: "123456789012".into(),
1732 principal_type: PrincipalType::User,
1733 source_identity: None,
1734 tags: None,
1735 };
1736 assert_eq!(aws_username_from_principal(&p), Some("alice".into()));
1737 }
1738
1739 #[test]
1740 fn aws_username_unset_for_assumed_role() {
1741 let p = Principal {
1742 arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
1743 user_id: "AROAOPS:session".into(),
1744 account_id: "123456789012".into(),
1745 principal_type: PrincipalType::AssumedRole,
1746 source_identity: None,
1747 tags: None,
1748 };
1749 assert_eq!(aws_username_from_principal(&p), None);
1750 }
1751
1752 #[test]
1753 fn principal_type_label_matches_aws_casing() {
1754 assert_eq!(principal_type_label(PrincipalType::User), "User");
1755 assert_eq!(
1756 principal_type_label(PrincipalType::AssumedRole),
1757 "AssumedRole"
1758 );
1759 assert_eq!(principal_type_label(PrincipalType::Root), "Account");
1760 }
1761
1762 #[test]
1763 fn build_condition_context_populates_global_keys() {
1764 let p = Principal {
1765 arn: "arn:aws:iam::123456789012:user/alice".into(),
1766 user_id: "AIDAALICE".into(),
1767 account_id: "123456789012".into(),
1768 principal_type: PrincipalType::User,
1769 source_identity: None,
1770 tags: None,
1771 };
1772 let addr: SocketAddr = "10.0.0.1:54321".parse().unwrap();
1773 let ctx = build_condition_context(&p, Some(addr), "us-east-1", false);
1774 assert_eq!(ctx.aws_username.as_deref(), Some("alice"));
1775 assert_eq!(ctx.aws_userid.as_deref(), Some("AIDAALICE"));
1776 assert_eq!(
1777 ctx.aws_principal_arn.as_deref(),
1778 Some("arn:aws:iam::123456789012:user/alice")
1779 );
1780 assert_eq!(ctx.aws_principal_account.as_deref(), Some("123456789012"));
1781 assert_eq!(ctx.aws_principal_type.as_deref(), Some("User"));
1782 assert_eq!(
1783 ctx.aws_source_ip.map(|i| i.to_string()).as_deref(),
1784 Some("10.0.0.1")
1785 );
1786 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-east-1"));
1787 assert_eq!(ctx.aws_secure_transport, Some(false));
1788 assert!(ctx.aws_current_time.is_some());
1789 assert!(ctx.aws_epoch_time.is_some());
1790 }
1791
1792 #[test]
1793 fn is_secure_transport_reads_x_forwarded_proto() {
1794 let mut headers = http::HeaderMap::new();
1795 headers.insert("x-forwarded-proto", "https".parse().unwrap());
1796 assert!(is_secure_transport(&headers));
1797 headers.insert("x-forwarded-proto", "http".parse().unwrap());
1798 assert!(!is_secure_transport(&headers));
1799 let empty = http::HeaderMap::new();
1800 assert!(!is_secure_transport(&empty));
1801 }
1802
1803 #[test]
1804 fn parse_account_from_arn_extracts_standard_shapes() {
1805 assert_eq!(
1806 parse_account_from_arn("arn:aws:sqs:us-east-1:123456789012:queue"),
1807 Some("123456789012".to_string())
1808 );
1809 assert_eq!(
1810 parse_account_from_arn("arn:aws:iam::123456789012:user/alice"),
1811 Some("123456789012".to_string())
1812 );
1813 }
1814
1815 #[test]
1816 fn parse_account_from_arn_returns_none_for_s3_empty_account() {
1817 assert_eq!(parse_account_from_arn("arn:aws:s3:::my-bucket"), None);
1819 assert_eq!(
1820 parse_account_from_arn("arn:aws:s3:::my-bucket/path/to/key"),
1821 None
1822 );
1823 }
1824
1825 #[test]
1826 fn parse_account_from_arn_returns_none_for_malformed() {
1827 assert_eq!(parse_account_from_arn(""), None);
1828 assert_eq!(parse_account_from_arn("not-an-arn"), None);
1829 assert_eq!(parse_account_from_arn("arn:aws:sqs:us-east-1"), None);
1830 assert_eq!(parse_account_from_arn("arn:aws:sqs"), None);
1831 }
1832
1833 #[test]
1834 fn extract_region_from_user_agent_finds_region_segment() {
1835 let mut headers = http::HeaderMap::new();
1836 headers.insert(
1837 "user-agent",
1838 "aws-sdk-rust/1.0 os/linux region/eu-central-1"
1839 .parse()
1840 .unwrap(),
1841 );
1842 assert_eq!(
1843 extract_region_from_user_agent(&headers),
1844 Some("eu-central-1".to_string())
1845 );
1846 }
1847
1848 #[test]
1849 fn extract_region_from_user_agent_none_without_header() {
1850 let headers = http::HeaderMap::new();
1851 assert_eq!(extract_region_from_user_agent(&headers), None);
1852 }
1853
1854 #[test]
1855 fn extract_region_from_user_agent_ignores_empty_region() {
1856 let mut headers = http::HeaderMap::new();
1857 headers.insert("user-agent", "aws-sdk-java region/".parse().unwrap());
1858 assert_eq!(extract_region_from_user_agent(&headers), None);
1859 }
1860
1861 #[test]
1862 fn extract_region_from_user_agent_none_when_no_region_marker() {
1863 let mut headers = http::HeaderMap::new();
1864 headers.insert("user-agent", "curl/7.79.1".parse().unwrap());
1865 assert_eq!(extract_region_from_user_agent(&headers), None);
1866 }
1867
1868 #[test]
1869 fn aws_username_none_for_root() {
1870 let p = Principal {
1871 arn: "arn:aws:iam::123456789012:root".into(),
1872 user_id: "123456789012".into(),
1873 account_id: "123456789012".into(),
1874 principal_type: PrincipalType::Root,
1875 source_identity: None,
1876 tags: None,
1877 };
1878 assert_eq!(aws_username_from_principal(&p), None);
1879 }
1880
1881 #[test]
1882 fn aws_username_bare_no_path() {
1883 let p = Principal {
1884 arn: "arn:aws:iam::123456789012:user/bob".into(),
1885 user_id: "AIDABOB".into(),
1886 account_id: "123456789012".into(),
1887 principal_type: PrincipalType::User,
1888 source_identity: None,
1889 tags: None,
1890 };
1891 assert_eq!(aws_username_from_principal(&p), Some("bob".into()));
1892 }
1893
1894 #[test]
1895 fn principal_type_label_covers_federated_and_unknown() {
1896 assert_eq!(
1897 principal_type_label(PrincipalType::FederatedUser),
1898 "FederatedUser"
1899 );
1900 assert_eq!(principal_type_label(PrincipalType::Unknown), "Unknown");
1901 }
1902
1903 #[test]
1904 fn build_condition_context_marks_secure_when_flag_set() {
1905 let p = Principal {
1906 arn: "arn:aws:iam::123456789012:user/alice".into(),
1907 user_id: "AIDAALICE".into(),
1908 account_id: "123456789012".into(),
1909 principal_type: PrincipalType::User,
1910 source_identity: None,
1911 tags: None,
1912 };
1913 let ctx = build_condition_context(&p, None, "us-west-2", true);
1914 assert_eq!(ctx.aws_secure_transport, Some(true));
1915 assert!(ctx.aws_source_ip.is_none());
1916 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-west-2"));
1917 }
1918
1919 #[test]
1920 fn is_secure_transport_case_insensitive() {
1921 let mut headers = http::HeaderMap::new();
1922 headers.insert("x-forwarded-proto", "HTTPS".parse().unwrap());
1923 assert!(is_secure_transport(&headers));
1924 }
1925
1926 #[test]
1927 fn is_secure_transport_non_ascii_bytes_false() {
1928 let mut headers = http::HeaderMap::new();
1929 headers.insert(
1930 "x-forwarded-proto",
1931 http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
1932 );
1933 assert!(!is_secure_transport(&headers));
1934 }
1935
1936 #[test]
1937 fn protocol_ext_error_status_is_bad_request() {
1938 assert_eq!(AwsProtocol::Query.error_status(), StatusCode::BAD_REQUEST);
1939 assert_eq!(AwsProtocol::Json.error_status(), StatusCode::BAD_REQUEST);
1940 assert_eq!(AwsProtocol::Rest.error_status(), StatusCode::BAD_REQUEST);
1941 assert_eq!(
1942 AwsProtocol::RestJson.error_status(),
1943 StatusCode::BAD_REQUEST
1944 );
1945 }
1946
1947 #[test]
1948 fn build_error_response_json_has_json_content_type() {
1949 let resp = build_error_response(
1950 StatusCode::BAD_REQUEST,
1951 "TestCode",
1952 "test msg",
1953 "req-1",
1954 AwsProtocol::Json,
1955 );
1956 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1957 let ct = resp
1958 .headers()
1959 .get("content-type")
1960 .unwrap()
1961 .to_str()
1962 .unwrap();
1963 assert!(ct.contains("json"));
1964 let rid = resp
1965 .headers()
1966 .get("x-amzn-requestid")
1967 .unwrap()
1968 .to_str()
1969 .unwrap();
1970 assert_eq!(rid, "req-1");
1971 }
1972
1973 #[test]
1974 fn build_error_response_rest_returns_xml_content_type() {
1975 let resp = build_error_response(
1976 StatusCode::NOT_FOUND,
1977 "NoSuchBucket",
1978 "bucket missing",
1979 "req-2",
1980 AwsProtocol::Rest,
1981 );
1982 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1983 let ct = resp
1984 .headers()
1985 .get("content-type")
1986 .unwrap()
1987 .to_str()
1988 .unwrap();
1989 assert!(ct.contains("xml"));
1990 }
1991
1992 #[test]
1993 fn build_error_response_query_returns_xml() {
1994 let resp = build_error_response(
1995 StatusCode::BAD_REQUEST,
1996 "InvalidParameter",
1997 "bad param",
1998 "req-3",
1999 AwsProtocol::Query,
2000 );
2001 let ct = resp
2002 .headers()
2003 .get("content-type")
2004 .unwrap()
2005 .to_str()
2006 .unwrap();
2007 assert!(ct.contains("xml"));
2008 }
2009
2010 #[test]
2015 fn build_error_response_with_multiline_message_does_not_panic() {
2016 let resp = build_error_response(
2017 StatusCode::INTERNAL_SERVER_ERROR,
2018 "ServiceException",
2019 "Lambda execution failed: container failed to start: docker start failed: \
2020 Error: unable to start container \"abc\": \
2021 failed to create new hosts file:\nhost-gateway is empty\n",
2022 "req-multi",
2023 AwsProtocol::Json,
2024 );
2025 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
2026 let msg = resp
2027 .headers()
2028 .get("x-amz-error-message")
2029 .expect("x-amz-error-message must be set even when input contains newlines")
2030 .to_str()
2031 .unwrap();
2032 assert!(!msg.contains('\n'));
2033 assert!(!msg.contains('\r'));
2034 assert!(msg.contains("Lambda execution failed"));
2035 assert!(msg.contains("host-gateway is empty"));
2036 }
2037
2038 #[test]
2039 fn build_error_response_with_control_chars_strips_them() {
2040 let resp = build_error_response(
2041 StatusCode::BAD_REQUEST,
2042 "Code\twith\ttabs",
2043 "msg\x00with\x01nulls",
2044 "req-ctrl",
2045 AwsProtocol::Json,
2046 );
2047 let code = resp
2048 .headers()
2049 .get("x-amz-error-code")
2050 .unwrap()
2051 .to_str()
2052 .unwrap();
2053 let msg = resp
2054 .headers()
2055 .get("x-amz-error-message")
2056 .unwrap()
2057 .to_str()
2058 .unwrap();
2059 assert!(!code.contains('\t'));
2060 assert!(!msg.contains('\x00'));
2061 assert!(!msg.contains('\x01'));
2062 }
2063
2064 #[test]
2065 fn sanitize_header_value_truncates_long_input() {
2066 let huge = "x".repeat(5_000);
2067 let out = sanitize_header_value(&huge);
2068 assert!(out.len() <= 1024);
2069 }
2070
2071 #[test]
2072 fn sanitize_header_value_collapses_consecutive_control_runs() {
2073 let out = sanitize_header_value("a\n\n\n\rb");
2074 assert_eq!(out, "a b");
2075 }
2076
2077 #[test]
2078 fn anonymous_s3_probe_finds_a_bucket_on_a_china_server() {
2079 struct RecordingProvider(parking_lot::Mutex<Vec<String>>);
2083 impl crate::auth::ResourcePolicyProvider for RecordingProvider {
2084 fn resource_policy(&self, _service: &str, _resource_arn: &str) -> Option<String> {
2085 None
2086 }
2087 fn resource_owner_account(&self, _service: &str, resource_arn: &str) -> Option<String> {
2088 self.0.lock().push(resource_arn.to_string());
2089 resource_arn
2090 .strip_prefix("arn:aws:s3:::")
2091 .filter(|bucket| *bucket == "my-bucket")
2092 .map(|_| "000000000000".to_string())
2093 }
2094 }
2095 let provider = Arc::new(RecordingProvider(parking_lot::Mutex::new(Vec::new())));
2096 let mut cfg = DispatchConfig::new("cn-north-1", "000000000000");
2097 cfg.resource_policy_provider = Some(provider.clone());
2098 let uri: http::Uri = "/my-bucket/key.txt".parse().unwrap();
2099 assert_eq!(
2100 anonymous_s3_bucket(&uri, &cfg),
2101 Some("my-bucket".to_string())
2102 );
2103 assert_eq!(
2104 *provider.0.lock(),
2105 vec!["arn:aws:s3:::my-bucket".to_string()]
2106 );
2107 }
2108
2109 #[test]
2110 fn dispatch_config_carries_opt_in_flags() {
2111 let cfg = DispatchConfig {
2112 region: "eu-west-1".to_string(),
2113 account_id: "000000000000".to_string(),
2114 verify_sigv4: true,
2115 iam_mode: IamMode::Strict,
2116 credential_resolver: None,
2117 policy_evaluator: None,
2118 resource_policy_provider: None,
2119 scp_resolver: None,
2120 };
2121 assert!(cfg.verify_sigv4);
2122 assert!(cfg.iam_mode.is_strict());
2123 assert!(cfg.resource_policy_provider.is_none());
2124 assert!(cfg.scp_resolver.is_none());
2125 }
2126
2127 fn s3_sigv4_headers() -> http::HeaderMap {
2128 let mut headers = http::HeaderMap::new();
2129 headers.insert(
2130 "authorization",
2131 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/s3/aws4_request, \
2132 SignedHeaders=host, Signature=fake"
2133 .parse()
2134 .unwrap(),
2135 );
2136 headers
2137 }
2138
2139 #[test]
2140 fn streaming_route_path_style_s3_put_object() {
2141 let headers = s3_sigv4_headers();
2142 assert_eq!(
2143 streaming_route(
2144 &http::Method::PUT,
2145 "/my-bucket/key.txt",
2146 &headers,
2147 &HashMap::new(),
2148 ),
2149 Some(("s3", "")),
2150 );
2151 }
2152
2153 #[test]
2154 fn streaming_route_path_style_create_bucket_skipped() {
2155 let headers = s3_sigv4_headers();
2158 assert_eq!(
2159 streaming_route(&http::Method::PUT, "/my-bucket", &headers, &HashMap::new(),),
2160 None,
2161 );
2162 }
2163
2164 #[test]
2165 fn s3_routing_path_prefixes_the_host_bucket() {
2166 assert_eq!(s3_routing_path("/", Some("b")), "/b");
2167 assert_eq!(s3_routing_path("", Some("b")), "/b");
2168 assert_eq!(s3_routing_path("/k.txt", Some("b")), "/b/k.txt");
2169 assert_eq!(s3_routing_path("/dir/k", Some("a.b")), "/a.b/dir/k");
2170 assert_eq!(s3_routing_path("/b/k", None), "/b/k");
2171 }
2172
2173 #[test]
2174 fn s3_routing_path_keeps_a_key_that_starts_with_the_bucket_name() {
2175 assert_eq!(
2178 s3_routing_path("/docs/intro.html", Some("docs")),
2179 "/docs/docs/intro.html"
2180 );
2181 assert_eq!(s3_routing_path("/docs", Some("docs")), "/docs/docs");
2182 }
2183
2184 #[test]
2185 fn streaming_route_path_style_create_bucket_with_trailing_slash_skipped() {
2186 let headers = s3_sigv4_headers();
2191 assert_eq!(
2192 streaming_route(&http::Method::PUT, "/my-bucket/", &headers, &HashMap::new(),),
2193 None,
2194 );
2195 }
2196
2197 #[test]
2198 fn streaming_route_path_style_doubled_slash_skipped() {
2199 let headers = s3_sigv4_headers();
2204 assert_eq!(
2205 streaming_route(
2206 &http::Method::PUT,
2207 "/my-bucket//",
2208 &headers,
2209 &HashMap::new()
2210 ),
2211 None,
2212 );
2213 }
2214
2215 #[test]
2216 fn streaming_route_path_style_key_with_trailing_slash_streams() {
2217 let headers = s3_sigv4_headers();
2220 assert_eq!(
2221 streaming_route(
2222 &http::Method::PUT,
2223 "/my-bucket/folder/",
2224 &headers,
2225 &HashMap::new(),
2226 ),
2227 Some(("s3", "")),
2228 );
2229 }
2230
2231 #[test]
2232 fn streaming_route_virtual_hosted_s3_put_object() {
2233 let mut headers = s3_sigv4_headers();
2234 headers.insert(
2235 "host",
2236 "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
2237 .parse()
2238 .unwrap(),
2239 );
2240 assert_eq!(
2245 streaming_route(&http::Method::PUT, "/hello.txt", &headers, &HashMap::new(),),
2246 Some(("s3", "")),
2247 );
2248 }
2249
2250 #[test]
2251 fn streaming_route_virtual_hosted_path_naming_the_bucket_streams() {
2252 let mut headers = s3_sigv4_headers();
2257 headers.insert(
2258 "host",
2259 "my-bucket.s3.us-east-1.amazonaws.com".parse().unwrap(),
2260 );
2261 for path in ["/my-bucket", "/my-bucket/", "/my-bucket/key.txt"] {
2262 assert_eq!(
2263 streaming_route(&http::Method::PUT, path, &headers, &HashMap::new()),
2264 Some(("s3", "")),
2265 "{path}",
2266 );
2267 }
2268 }
2269
2270 #[test]
2271 fn streaming_route_virtual_hosted_s3_root_skipped() {
2272 let mut headers = s3_sigv4_headers();
2275 headers.insert(
2276 "host",
2277 "vhost-bucket.s3.us-east-1.localhost.localstack.cloud:4566"
2278 .parse()
2279 .unwrap(),
2280 );
2281 assert_eq!(
2282 streaming_route(&http::Method::PUT, "/", &headers, &HashMap::new()),
2283 None,
2284 );
2285 }
2286
2287 #[test]
2288 fn streaming_route_ecr_blob_upload() {
2289 let headers = http::HeaderMap::new();
2290 assert_eq!(
2291 streaming_route(
2292 &http::Method::PATCH,
2293 "/v2/my-repo/blobs/uploads/abcd1234",
2294 &headers,
2295 &HashMap::new(),
2296 ),
2297 Some(("ecr", "")),
2298 );
2299 assert_eq!(
2300 streaming_route(
2301 &http::Method::PUT,
2302 "/v2/my-repo/blobs/uploads/abcd1234",
2303 &headers,
2304 &HashMap::new(),
2305 ),
2306 Some(("ecr", "")),
2307 );
2308 }
2309
2310 #[test]
2311 fn streaming_route_presigned_v4_s3_put() {
2312 let headers = http::HeaderMap::new();
2313 let mut query_params = HashMap::new();
2314 query_params.insert(
2315 "X-Amz-Credential".to_string(),
2316 "test/20240101/us-east-1/s3/aws4_request".to_string(),
2317 );
2318 assert_eq!(
2319 streaming_route(
2320 &http::Method::PUT,
2321 "/my-bucket/key.txt",
2322 &headers,
2323 &query_params,
2324 ),
2325 Some(("s3", "")),
2326 );
2327 }
2328
2329 #[test]
2330 fn streaming_route_non_s3_auth_header_skipped() {
2331 let mut headers = http::HeaderMap::new();
2334 headers.insert(
2335 "authorization",
2336 "AWS4-HMAC-SHA256 Credential=test/20240101/us-east-1/lambda/aws4_request, \
2337 SignedHeaders=host, Signature=fake"
2338 .parse()
2339 .unwrap(),
2340 );
2341 assert_eq!(
2342 streaming_route(
2343 &http::Method::PUT,
2344 "/my-bucket/key.txt",
2345 &headers,
2346 &HashMap::new(),
2347 ),
2348 None,
2349 );
2350 }
2351
2352 #[test]
2353 fn streaming_route_get_skipped() {
2354 let headers = s3_sigv4_headers();
2355 assert_eq!(
2356 streaming_route(
2357 &http::Method::GET,
2358 "/my-bucket/key.txt",
2359 &headers,
2360 &HashMap::new(),
2361 ),
2362 None,
2363 );
2364 }
2365}