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