1use axum::body::Body;
2use axum::extract::{ConnectInfo, Extension, Query};
3use axum::http::{Request, StatusCode};
4use axum::response::Response;
5use std::collections::HashMap;
6use std::net::SocketAddr;
7use std::sync::Arc;
8
9use crate::auth::{
10 is_root_bypass, ConditionContext, CredentialResolver, IamMode, IamPolicyEvaluator, Principal,
11 PrincipalType, ResourcePolicyProvider,
12};
13use crate::protocol::{self, AwsProtocol};
14use crate::registry::ServiceRegistry;
15use crate::service::{AwsRequest, ResponseBody};
16
17pub async fn dispatch(
19 ConnectInfo(remote_addr): ConnectInfo<SocketAddr>,
20 Extension(registry): Extension<Arc<ServiceRegistry>>,
21 Extension(config): Extension<Arc<DispatchConfig>>,
22 Query(query_params): Query<HashMap<String, String>>,
23 request: Request<Body>,
24) -> Response<Body> {
25 let remote_addr = Some(remote_addr);
26 let request_id = uuid::Uuid::new_v4().to_string();
27
28 let (parts, body) = request.into_parts();
29 const MAX_BODY_BYTES: usize = 128 * 1024 * 1024;
35 let body_bytes = match axum::body::to_bytes(body, MAX_BODY_BYTES).await {
36 Ok(b) => b,
37 Err(_) => {
38 return build_error_response(
39 StatusCode::PAYLOAD_TOO_LARGE,
40 "RequestEntityTooLarge",
41 "Request body too large",
42 &request_id,
43 AwsProtocol::Query,
44 );
45 }
46 };
47
48 let detected = match protocol::detect_service(&parts.headers, &query_params, &body_bytes) {
50 Some(d) => d,
51 None => {
52 if parts.method == http::Method::OPTIONS {
58 protocol::DetectedRequest {
59 service: "s3".to_string(),
60 action: String::new(),
61 protocol: AwsProtocol::Rest,
62 }
63 } else if parts.uri.path() == "/v2" || parts.uri.path().starts_with("/v2/") {
64 protocol::DetectedRequest {
68 service: "ecr".to_string(),
69 action: String::new(),
70 protocol: AwsProtocol::Rest,
71 }
72 } else if !parts.uri.path().starts_with("/_") {
73 protocol::DetectedRequest {
78 service: "apigateway".to_string(),
79 action: String::new(),
80 protocol: AwsProtocol::RestJson,
81 }
82 } else {
83 return build_error_response(
84 StatusCode::BAD_REQUEST,
85 "MissingAction",
86 "Could not determine target service or action from request",
87 &request_id,
88 AwsProtocol::Query,
89 );
90 }
91 }
92 };
93
94 let service = match registry.get(&detected.service) {
96 Some(s) => s,
97 None => {
98 return build_error_response(
99 detected.protocol.error_status(),
100 "UnknownService",
101 &format!("Service '{}' is not available", detected.service),
102 &request_id,
103 detected.protocol,
104 );
105 }
106 };
107
108 let auth_header = parts
110 .headers
111 .get("authorization")
112 .and_then(|v| v.to_str().ok())
113 .unwrap_or("");
114 let header_info = fakecloud_aws::sigv4::parse_sigv4(auth_header);
115 let presigned_info = if header_info.is_none() {
116 fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params).map(|p| p.as_info())
118 } else {
119 None
120 };
121 let sigv4_info = header_info.or(presigned_info);
122 let access_key_id = sigv4_info.as_ref().map(|info| info.access_key.clone());
123
124 let host_info = protocol::parse_routing_host_from_headers(&parts.headers);
130
131 let region = sigv4_info
132 .map(|info| info.region)
133 .or_else(|| host_info.as_ref().map(|h| h.region.clone()))
134 .or_else(|| extract_region_from_user_agent(&parts.headers))
135 .unwrap_or_else(|| config.region.clone());
136
137 let caller_akid = access_key_id.as_deref().unwrap_or("");
143 let resolved = if !caller_akid.is_empty() && !is_root_bypass(caller_akid) {
144 config
145 .credential_resolver
146 .as_ref()
147 .and_then(|r| r.resolve(caller_akid))
148 } else {
149 None
150 };
151 let caller_principal = resolved.as_ref().map(|r| r.principal.clone());
152 let caller_session_policies = resolved
153 .as_ref()
154 .map(|r| r.session_policies.clone())
155 .unwrap_or_default();
156
157 if config.verify_sigv4 && !is_root_bypass(caller_akid) && config.credential_resolver.is_some() {
162 let amz_date = parts
163 .headers
164 .get("x-amz-date")
165 .and_then(|v| v.to_str().ok());
166 let parsed = fakecloud_aws::sigv4::parse_sigv4_header(auth_header, amz_date)
167 .or_else(|| fakecloud_aws::sigv4::parse_sigv4_presigned(&query_params));
168 let parsed = match parsed {
169 Some(p) => p,
170 None => {
171 return build_error_response(
172 StatusCode::FORBIDDEN,
173 "IncompleteSignature",
174 "Request is missing or has a malformed AWS Signature",
175 &request_id,
176 detected.protocol,
177 );
178 }
179 };
180 let resolved_for_verify = match resolved.as_ref() {
181 Some(r) => r,
182 None => {
183 return build_error_response(
184 StatusCode::FORBIDDEN,
185 "InvalidClientTokenId",
186 "The security token included in the request is invalid",
187 &request_id,
188 detected.protocol,
189 );
190 }
191 };
192 let headers_vec = fakecloud_aws::sigv4::headers_from_http(&parts.headers);
193 let raw_query_for_verify = parts.uri.query().unwrap_or("").to_string();
194 let verify_req = fakecloud_aws::sigv4::VerifyRequest {
195 method: parts.method.as_str(),
196 path: parts.uri.path(),
197 query: &raw_query_for_verify,
198 headers: &headers_vec,
199 body: &body_bytes,
200 };
201 match fakecloud_aws::sigv4::verify(
202 &parsed,
203 &verify_req,
204 &resolved_for_verify.secret_access_key,
205 chrono::Utc::now(),
206 ) {
207 Ok(()) => {}
208 Err(fakecloud_aws::sigv4::SigV4Error::RequestTimeTooSkewed { .. }) => {
209 return build_error_response(
210 StatusCode::FORBIDDEN,
211 "RequestTimeTooSkewed",
212 "The difference between the request time and the current time is too large",
213 &request_id,
214 detected.protocol,
215 );
216 }
217 Err(fakecloud_aws::sigv4::SigV4Error::InvalidDate(msg)) => {
218 return build_error_response(
219 StatusCode::FORBIDDEN,
220 "IncompleteSignature",
221 &format!("Invalid x-amz-date: {msg}"),
222 &request_id,
223 detected.protocol,
224 );
225 }
226 Err(fakecloud_aws::sigv4::SigV4Error::Malformed(msg)) => {
227 return build_error_response(
228 StatusCode::FORBIDDEN,
229 "IncompleteSignature",
230 &format!("Malformed SigV4 signature: {msg}"),
231 &request_id,
232 detected.protocol,
233 );
234 }
235 Err(fakecloud_aws::sigv4::SigV4Error::SignatureMismatch) => {
236 return build_error_response(
237 StatusCode::FORBIDDEN,
238 "SignatureDoesNotMatch",
239 "The request signature we calculated does not match the signature you provided",
240 &request_id,
241 detected.protocol,
242 );
243 }
244 }
245 }
246
247 let wire_path = parts.uri.path();
252 let path = if detected.service == "s3" {
253 if let Some(bucket) = host_info.as_ref().and_then(|h| h.bucket.as_deref()) {
254 let prefix_with_slash = format!("/{bucket}/");
255 let is_bucket_root = wire_path.trim_end_matches('/') == format!("/{bucket}");
256 if wire_path.starts_with(&prefix_with_slash) || is_bucket_root {
257 wire_path.to_string()
258 } else if wire_path == "/" || wire_path.is_empty() {
259 format!("/{bucket}")
260 } else {
261 format!("/{bucket}{wire_path}")
262 }
263 } else {
264 wire_path.to_string()
265 }
266 } else {
267 wire_path.to_string()
268 };
269 let raw_query = parts.uri.query().unwrap_or("").to_string();
270 let path_segments: Vec<String> = path
271 .split('/')
272 .filter(|s| !s.is_empty())
273 .map(|s| s.to_string())
274 .collect();
275
276 if detected.protocol == AwsProtocol::Json
278 && !body_bytes.is_empty()
279 && serde_json::from_slice::<serde_json::Value>(&body_bytes).is_err()
280 {
281 return build_error_response(
282 StatusCode::BAD_REQUEST,
283 "SerializationException",
284 "Start of structure or map found where not expected",
285 &request_id,
286 AwsProtocol::Json,
287 );
288 }
289
290 let mut all_params = query_params;
292 if detected.protocol == AwsProtocol::Query {
293 let body_params = protocol::parse_query_body(&body_bytes);
294 for (k, v) in body_params {
295 all_params.entry(k).or_insert(v);
296 }
297 }
298
299 let aws_request = AwsRequest {
300 service: detected.service.clone(),
301 action: detected.action.clone(),
302 region,
303 account_id: caller_principal
304 .as_ref()
305 .map(|p| p.account_id.clone())
306 .unwrap_or_else(|| config.account_id.clone()),
307 request_id: request_id.clone(),
308 headers: parts.headers,
309 query_params: all_params,
310 body: body_bytes,
311 path_segments,
312 raw_path: path,
313 raw_query,
314 method: parts.method,
315 is_query_protocol: detected.protocol == AwsProtocol::Query,
316 access_key_id,
317 principal: caller_principal,
318 };
319
320 tracing::info!(
321 service = %aws_request.service,
322 action = %aws_request.action,
323 request_id = %aws_request.request_id,
324 "handling request"
325 );
326
327 if config.iam_mode.is_enabled()
334 && service.iam_enforceable()
335 && !is_root_bypass(aws_request.access_key_id.as_deref().unwrap_or(""))
336 {
337 if let Some(evaluator) = config.policy_evaluator.as_ref() {
338 if let Some(principal) = aws_request.principal.as_ref() {
339 if !principal.is_root() {
340 if let Some(iam_action) = service.iam_action_for(&aws_request) {
341 let mut condition_context = build_condition_context(
342 principal,
343 remote_addr,
344 &aws_request.region,
345 is_secure_transport(&aws_request.headers),
346 );
347 condition_context.service_keys =
348 service.iam_condition_keys_for(&aws_request, &iam_action);
349
350 match service.resource_tags_for(&iam_action.resource) {
353 Some(tags) => condition_context.resource_tags = Some(tags),
354 None => tracing::debug!(
355 target: "fakecloud::iam::audit",
356 service = %detected.service,
357 resource = %iam_action.resource,
358 "service does not expose resource tags for ABAC; skipping aws:ResourceTag/* evaluation"
359 ),
360 }
361 match service.request_tags_from(&aws_request, iam_action.action) {
363 Some(tags) => condition_context.request_tags = Some(tags),
364 None => tracing::debug!(
365 target: "fakecloud::iam::audit",
366 service = %detected.service,
367 action = %iam_action.action_string(),
368 "service does not expose request tags for ABAC; skipping aws:RequestTag/* / aws:TagKeys evaluation"
369 ),
370 }
371 condition_context.principal_tags = principal.tags.clone();
373
374 let resource_policy_json =
383 config.resource_policy_provider.as_ref().and_then(|p| {
384 p.resource_policy(&detected.service, &iam_action.resource)
385 });
386 let resource_account_id = parse_account_from_arn(&iam_action.resource)
392 .unwrap_or_else(|| principal.account_id.clone());
393 let scps = config
400 .scp_resolver
401 .as_ref()
402 .and_then(|r| r.scps_for(principal));
403 let decision = evaluator.evaluate_with_resource_policy(
404 principal,
405 &iam_action,
406 &condition_context,
407 resource_policy_json.as_deref(),
408 &resource_account_id,
409 &caller_session_policies,
410 scps.as_deref(),
411 );
412 if !decision.is_allow() {
413 tracing::warn!(
414 target: "fakecloud::iam::audit",
415 service = %detected.service,
416 action = %iam_action.action_string(),
417 resource = %iam_action.resource,
418 principal = %principal.arn,
419 resource_policy_present = resource_policy_json.is_some(),
420 decision = ?decision,
421 mode = %config.iam_mode,
422 request_id = %request_id,
423 "IAM policy evaluation denied request"
424 );
425 if config.iam_mode.is_strict() {
426 return build_error_response(
427 StatusCode::FORBIDDEN,
428 "AccessDeniedException",
429 &format!(
430 "User: {} is not authorized to perform: {} on resource: {}",
431 principal.arn,
432 iam_action.action_string(),
433 iam_action.resource
434 ),
435 &request_id,
436 detected.protocol,
437 );
438 }
439 }
442 } else {
443 tracing::warn!(
448 target: "fakecloud::iam::audit",
449 service = %detected.service,
450 action = %aws_request.action,
451 "service is iam_enforceable but has no IamAction mapping for this action; skipping evaluation"
452 );
453 }
454 }
455 }
456 }
457 }
458
459 match service.handle(aws_request).await {
460 Ok(resp) => {
461 let mut builder = Response::builder()
462 .status(resp.status)
463 .header("x-amzn-requestid", &request_id)
464 .header("x-amz-request-id", &request_id);
465
466 if !resp.content_type.is_empty() {
467 builder = builder.header("content-type", &resp.content_type);
468 }
469
470 let has_content_length = resp
471 .headers
472 .iter()
473 .any(|(k, _)| k.as_str().eq_ignore_ascii_case("content-length"));
474
475 for (k, v) in &resp.headers {
476 builder = builder.header(k, v);
477 }
478
479 match resp.body {
480 ResponseBody::Bytes(b) => builder.body(Body::from(b)).unwrap(),
481 ResponseBody::File { file, size } => {
482 let stream = tokio_util::io::ReaderStream::new(file);
483 let body = Body::from_stream(stream);
484 if !has_content_length {
485 builder = builder.header("content-length", size.to_string());
486 }
487 builder.body(body).unwrap()
488 }
489 }
490 }
491 Err(err) => {
492 tracing::warn!(
493 service = %detected.service,
494 action = %detected.action,
495 error = %err,
496 "request failed"
497 );
498 let error_headers = err.response_headers().to_vec();
499 let mut resp = build_error_response_with_fields(
500 err.status(),
501 err.code(),
502 &err.message(),
503 &request_id,
504 detected.protocol,
505 err.extra_fields(),
506 );
507 for (k, v) in &error_headers {
508 if let (Ok(name), Ok(val)) = (
509 k.parse::<http::header::HeaderName>(),
510 v.parse::<http::header::HeaderValue>(),
511 ) {
512 resp.headers_mut().insert(name, val);
513 }
514 }
515 resp
516 }
517 }
518}
519
520#[derive(Clone)]
522pub struct DispatchConfig {
523 pub region: String,
524 pub account_id: String,
525 pub verify_sigv4: bool,
529 pub iam_mode: IamMode,
534 pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
538 pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
542 pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
549 pub scp_resolver: Option<Arc<dyn crate::auth::ScpResolver>>,
556}
557
558impl std::fmt::Debug for DispatchConfig {
559 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560 f.debug_struct("DispatchConfig")
561 .field("region", &self.region)
562 .field("account_id", &self.account_id)
563 .field("verify_sigv4", &self.verify_sigv4)
564 .field("iam_mode", &self.iam_mode)
565 .field(
566 "credential_resolver",
567 &self
568 .credential_resolver
569 .as_ref()
570 .map(|_| "<CredentialResolver>"),
571 )
572 .field(
573 "policy_evaluator",
574 &self
575 .policy_evaluator
576 .as_ref()
577 .map(|_| "<IamPolicyEvaluator>"),
578 )
579 .field(
580 "resource_policy_provider",
581 &self
582 .resource_policy_provider
583 .as_ref()
584 .map(|_| "<ResourcePolicyProvider>"),
585 )
586 .field(
587 "scp_resolver",
588 &self.scp_resolver.as_ref().map(|_| "<ScpResolver>"),
589 )
590 .finish()
591 }
592}
593
594impl DispatchConfig {
595 pub fn new(region: impl Into<String>, account_id: impl Into<String>) -> Self {
598 Self {
599 region: region.into(),
600 account_id: account_id.into(),
601 verify_sigv4: false,
602 iam_mode: IamMode::Off,
603 credential_resolver: None,
604 policy_evaluator: None,
605 resource_policy_provider: None,
606 scp_resolver: None,
607 }
608 }
609}
610
611fn parse_account_from_arn(arn: &str) -> Option<String> {
620 let mut parts = arn.splitn(6, ':');
621 if parts.next()? != "arn" {
622 return None;
623 }
624 let _partition = parts.next()?;
625 let _service = parts.next()?;
626 let _region = parts.next()?;
627 let account = parts.next()?;
628 parts.next()?;
631 if account.is_empty() {
632 None
633 } else {
634 Some(account.to_string())
635 }
636}
637
638fn extract_region_from_user_agent(headers: &http::HeaderMap) -> Option<String> {
640 let ua = headers.get("user-agent")?.to_str().ok()?;
641 for part in ua.split_whitespace() {
642 if let Some(region) = part.strip_prefix("region/") {
643 if !region.is_empty() {
644 return Some(region.to_string());
645 }
646 }
647 }
648 None
649}
650
651fn build_error_response(
652 status: StatusCode,
653 code: &str,
654 message: &str,
655 request_id: &str,
656 protocol: AwsProtocol,
657) -> Response<Body> {
658 build_error_response_with_fields(status, code, message, request_id, protocol, &[])
659}
660
661fn build_error_response_with_fields(
662 status: StatusCode,
663 code: &str,
664 message: &str,
665 request_id: &str,
666 protocol: AwsProtocol,
667 extra_fields: &[(String, String)],
668) -> Response<Body> {
669 let (status, content_type, body) = match protocol {
670 AwsProtocol::Query => {
671 fakecloud_aws::error::xml_error_response(status, code, message, request_id)
672 }
673 AwsProtocol::Rest => fakecloud_aws::error::s3_xml_error_response_with_fields(
674 status,
675 code,
676 message,
677 request_id,
678 extra_fields,
679 ),
680 AwsProtocol::Json | AwsProtocol::RestJson => {
681 fakecloud_aws::error::json_error_response(status, code, message)
682 }
683 };
684
685 Response::builder()
686 .status(status)
687 .header("content-type", content_type)
688 .header("x-amzn-requestid", request_id)
689 .header("x-amz-request-id", request_id)
690 .body(Body::from(body))
691 .unwrap()
692}
693
694fn build_condition_context(
699 principal: &Principal,
700 remote_addr: Option<SocketAddr>,
701 region: &str,
702 secure_transport: bool,
703) -> ConditionContext {
704 let now = chrono::Utc::now();
705 ConditionContext {
706 aws_username: aws_username_from_principal(principal),
707 aws_userid: Some(principal.user_id.clone()),
708 aws_principal_arn: Some(principal.arn.clone()),
709 aws_principal_account: Some(principal.account_id.clone()),
710 aws_principal_type: Some(principal_type_label(principal.principal_type).to_string()),
711 aws_source_ip: remote_addr.map(|sa| sa.ip()),
712 aws_current_time: Some(now),
713 aws_epoch_time: Some(now.timestamp()),
714 aws_secure_transport: Some(secure_transport),
715 aws_requested_region: Some(region.to_string()),
716 service_keys: Default::default(),
717 resource_tags: None,
718 request_tags: None,
719 principal_tags: None,
720 }
721}
722
723fn aws_username_from_principal(principal: &Principal) -> Option<String> {
727 if principal.principal_type != PrincipalType::User {
728 return None;
729 }
730 let after = principal.arn.rsplit_once(":user/").map(|(_, s)| s)?;
731 Some(after.rsplit('/').next().unwrap_or(after).to_string())
733}
734
735fn principal_type_label(t: PrincipalType) -> &'static str {
738 match t {
739 PrincipalType::User => "User",
740 PrincipalType::AssumedRole => "AssumedRole",
741 PrincipalType::FederatedUser => "FederatedUser",
742 PrincipalType::Root => "Account",
743 PrincipalType::Unknown => "Unknown",
744 }
745}
746
747fn is_secure_transport(headers: &http::HeaderMap) -> bool {
753 headers
754 .get("x-forwarded-proto")
755 .and_then(|v| v.to_str().ok())
756 .map(|s| s.eq_ignore_ascii_case("https"))
757 .unwrap_or(false)
758}
759
760trait ProtocolExt {
761 fn error_status(&self) -> StatusCode;
762}
763
764impl ProtocolExt for AwsProtocol {
765 fn error_status(&self) -> StatusCode {
766 StatusCode::BAD_REQUEST
767 }
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn dispatch_config_new_defaults_to_off() {
776 let cfg = DispatchConfig::new("us-east-1", "123456789012");
777 assert_eq!(cfg.region, "us-east-1");
778 assert_eq!(cfg.account_id, "123456789012");
779 assert!(!cfg.verify_sigv4);
780 assert_eq!(cfg.iam_mode, IamMode::Off);
781 }
782
783 #[test]
784 fn aws_username_strips_iam_path_for_users() {
785 let p = Principal {
786 arn: "arn:aws:iam::123456789012:user/engineering/alice".into(),
787 user_id: "AIDAALICE".into(),
788 account_id: "123456789012".into(),
789 principal_type: PrincipalType::User,
790 source_identity: None,
791 tags: None,
792 };
793 assert_eq!(aws_username_from_principal(&p), Some("alice".into()));
794 }
795
796 #[test]
797 fn aws_username_unset_for_assumed_role() {
798 let p = Principal {
799 arn: "arn:aws:sts::123456789012:assumed-role/ops/session".into(),
800 user_id: "AROAOPS:session".into(),
801 account_id: "123456789012".into(),
802 principal_type: PrincipalType::AssumedRole,
803 source_identity: None,
804 tags: None,
805 };
806 assert_eq!(aws_username_from_principal(&p), None);
807 }
808
809 #[test]
810 fn principal_type_label_matches_aws_casing() {
811 assert_eq!(principal_type_label(PrincipalType::User), "User");
812 assert_eq!(
813 principal_type_label(PrincipalType::AssumedRole),
814 "AssumedRole"
815 );
816 assert_eq!(principal_type_label(PrincipalType::Root), "Account");
817 }
818
819 #[test]
820 fn build_condition_context_populates_global_keys() {
821 let p = Principal {
822 arn: "arn:aws:iam::123456789012:user/alice".into(),
823 user_id: "AIDAALICE".into(),
824 account_id: "123456789012".into(),
825 principal_type: PrincipalType::User,
826 source_identity: None,
827 tags: None,
828 };
829 let addr: SocketAddr = "10.0.0.1:54321".parse().unwrap();
830 let ctx = build_condition_context(&p, Some(addr), "us-east-1", false);
831 assert_eq!(ctx.aws_username.as_deref(), Some("alice"));
832 assert_eq!(ctx.aws_userid.as_deref(), Some("AIDAALICE"));
833 assert_eq!(
834 ctx.aws_principal_arn.as_deref(),
835 Some("arn:aws:iam::123456789012:user/alice")
836 );
837 assert_eq!(ctx.aws_principal_account.as_deref(), Some("123456789012"));
838 assert_eq!(ctx.aws_principal_type.as_deref(), Some("User"));
839 assert_eq!(
840 ctx.aws_source_ip.map(|i| i.to_string()).as_deref(),
841 Some("10.0.0.1")
842 );
843 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-east-1"));
844 assert_eq!(ctx.aws_secure_transport, Some(false));
845 assert!(ctx.aws_current_time.is_some());
846 assert!(ctx.aws_epoch_time.is_some());
847 }
848
849 #[test]
850 fn is_secure_transport_reads_x_forwarded_proto() {
851 let mut headers = http::HeaderMap::new();
852 headers.insert("x-forwarded-proto", "https".parse().unwrap());
853 assert!(is_secure_transport(&headers));
854 headers.insert("x-forwarded-proto", "http".parse().unwrap());
855 assert!(!is_secure_transport(&headers));
856 let empty = http::HeaderMap::new();
857 assert!(!is_secure_transport(&empty));
858 }
859
860 #[test]
861 fn parse_account_from_arn_extracts_standard_shapes() {
862 assert_eq!(
863 parse_account_from_arn("arn:aws:sqs:us-east-1:123456789012:queue"),
864 Some("123456789012".to_string())
865 );
866 assert_eq!(
867 parse_account_from_arn("arn:aws:iam::123456789012:user/alice"),
868 Some("123456789012".to_string())
869 );
870 }
871
872 #[test]
873 fn parse_account_from_arn_returns_none_for_s3_empty_account() {
874 assert_eq!(parse_account_from_arn("arn:aws:s3:::my-bucket"), None);
876 assert_eq!(
877 parse_account_from_arn("arn:aws:s3:::my-bucket/path/to/key"),
878 None
879 );
880 }
881
882 #[test]
883 fn parse_account_from_arn_returns_none_for_malformed() {
884 assert_eq!(parse_account_from_arn(""), None);
885 assert_eq!(parse_account_from_arn("not-an-arn"), None);
886 assert_eq!(parse_account_from_arn("arn:aws:sqs:us-east-1"), None);
887 assert_eq!(parse_account_from_arn("arn:aws:sqs"), None);
888 }
889
890 #[test]
891 fn extract_region_from_user_agent_finds_region_segment() {
892 let mut headers = http::HeaderMap::new();
893 headers.insert(
894 "user-agent",
895 "aws-sdk-rust/1.0 os/linux region/eu-central-1"
896 .parse()
897 .unwrap(),
898 );
899 assert_eq!(
900 extract_region_from_user_agent(&headers),
901 Some("eu-central-1".to_string())
902 );
903 }
904
905 #[test]
906 fn extract_region_from_user_agent_none_without_header() {
907 let headers = http::HeaderMap::new();
908 assert_eq!(extract_region_from_user_agent(&headers), None);
909 }
910
911 #[test]
912 fn extract_region_from_user_agent_ignores_empty_region() {
913 let mut headers = http::HeaderMap::new();
914 headers.insert("user-agent", "aws-sdk-java region/".parse().unwrap());
915 assert_eq!(extract_region_from_user_agent(&headers), None);
916 }
917
918 #[test]
919 fn extract_region_from_user_agent_none_when_no_region_marker() {
920 let mut headers = http::HeaderMap::new();
921 headers.insert("user-agent", "curl/7.79.1".parse().unwrap());
922 assert_eq!(extract_region_from_user_agent(&headers), None);
923 }
924
925 #[test]
926 fn aws_username_none_for_root() {
927 let p = Principal {
928 arn: "arn:aws:iam::123456789012:root".into(),
929 user_id: "123456789012".into(),
930 account_id: "123456789012".into(),
931 principal_type: PrincipalType::Root,
932 source_identity: None,
933 tags: None,
934 };
935 assert_eq!(aws_username_from_principal(&p), None);
936 }
937
938 #[test]
939 fn aws_username_bare_no_path() {
940 let p = Principal {
941 arn: "arn:aws:iam::123456789012:user/bob".into(),
942 user_id: "AIDABOB".into(),
943 account_id: "123456789012".into(),
944 principal_type: PrincipalType::User,
945 source_identity: None,
946 tags: None,
947 };
948 assert_eq!(aws_username_from_principal(&p), Some("bob".into()));
949 }
950
951 #[test]
952 fn principal_type_label_covers_federated_and_unknown() {
953 assert_eq!(
954 principal_type_label(PrincipalType::FederatedUser),
955 "FederatedUser"
956 );
957 assert_eq!(principal_type_label(PrincipalType::Unknown), "Unknown");
958 }
959
960 #[test]
961 fn build_condition_context_marks_secure_when_flag_set() {
962 let p = Principal {
963 arn: "arn:aws:iam::123456789012:user/alice".into(),
964 user_id: "AIDAALICE".into(),
965 account_id: "123456789012".into(),
966 principal_type: PrincipalType::User,
967 source_identity: None,
968 tags: None,
969 };
970 let ctx = build_condition_context(&p, None, "us-west-2", true);
971 assert_eq!(ctx.aws_secure_transport, Some(true));
972 assert!(ctx.aws_source_ip.is_none());
973 assert_eq!(ctx.aws_requested_region.as_deref(), Some("us-west-2"));
974 }
975
976 #[test]
977 fn is_secure_transport_case_insensitive() {
978 let mut headers = http::HeaderMap::new();
979 headers.insert("x-forwarded-proto", "HTTPS".parse().unwrap());
980 assert!(is_secure_transport(&headers));
981 }
982
983 #[test]
984 fn is_secure_transport_non_ascii_bytes_false() {
985 let mut headers = http::HeaderMap::new();
986 headers.insert(
987 "x-forwarded-proto",
988 http::HeaderValue::from_bytes(&[0xFF, 0xFE]).unwrap(),
989 );
990 assert!(!is_secure_transport(&headers));
991 }
992
993 #[test]
994 fn protocol_ext_error_status_is_bad_request() {
995 assert_eq!(AwsProtocol::Query.error_status(), StatusCode::BAD_REQUEST);
996 assert_eq!(AwsProtocol::Json.error_status(), StatusCode::BAD_REQUEST);
997 assert_eq!(AwsProtocol::Rest.error_status(), StatusCode::BAD_REQUEST);
998 assert_eq!(
999 AwsProtocol::RestJson.error_status(),
1000 StatusCode::BAD_REQUEST
1001 );
1002 }
1003
1004 #[test]
1005 fn build_error_response_json_has_json_content_type() {
1006 let resp = build_error_response(
1007 StatusCode::BAD_REQUEST,
1008 "TestCode",
1009 "test msg",
1010 "req-1",
1011 AwsProtocol::Json,
1012 );
1013 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1014 let ct = resp
1015 .headers()
1016 .get("content-type")
1017 .unwrap()
1018 .to_str()
1019 .unwrap();
1020 assert!(ct.contains("json"));
1021 let rid = resp
1022 .headers()
1023 .get("x-amzn-requestid")
1024 .unwrap()
1025 .to_str()
1026 .unwrap();
1027 assert_eq!(rid, "req-1");
1028 }
1029
1030 #[test]
1031 fn build_error_response_rest_returns_xml_content_type() {
1032 let resp = build_error_response(
1033 StatusCode::NOT_FOUND,
1034 "NoSuchBucket",
1035 "bucket missing",
1036 "req-2",
1037 AwsProtocol::Rest,
1038 );
1039 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1040 let ct = resp
1041 .headers()
1042 .get("content-type")
1043 .unwrap()
1044 .to_str()
1045 .unwrap();
1046 assert!(ct.contains("xml"));
1047 }
1048
1049 #[test]
1050 fn build_error_response_query_returns_xml() {
1051 let resp = build_error_response(
1052 StatusCode::BAD_REQUEST,
1053 "InvalidParameter",
1054 "bad param",
1055 "req-3",
1056 AwsProtocol::Query,
1057 );
1058 let ct = resp
1059 .headers()
1060 .get("content-type")
1061 .unwrap()
1062 .to_str()
1063 .unwrap();
1064 assert!(ct.contains("xml"));
1065 }
1066
1067 #[test]
1068 fn dispatch_config_carries_opt_in_flags() {
1069 let cfg = DispatchConfig {
1070 region: "eu-west-1".to_string(),
1071 account_id: "000000000000".to_string(),
1072 verify_sigv4: true,
1073 iam_mode: IamMode::Strict,
1074 credential_resolver: None,
1075 policy_evaluator: None,
1076 resource_policy_provider: None,
1077 scp_resolver: None,
1078 };
1079 assert!(cfg.verify_sigv4);
1080 assert!(cfg.iam_mode.is_strict());
1081 assert!(cfg.resource_policy_provider.is_none());
1082 assert!(cfg.scp_resolver.is_none());
1083 }
1084}