Skip to main content

fakecloud_core/
dispatch.rs

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
18/// The main dispatch handler. All HTTP requests come through here.
19pub 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    // Streaming opt-in: if the route is a known large-body S3 / ECR
32    // upload, we skip the buffered `to_bytes` step entirely and hand
33    // the raw body to the service handler. The handler spills it to
34    // disk on the fly. Header-only detection covers every streaming
35    // candidate (none of them rely on form-body sniffing).
36    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        // Header-only detection agrees with the URL match — covers S3
45        // PUT object (SigV4 service=s3 in Authorization).
46        (Some(sr), Some(detected)) if sr.0 == detected.service => Some(detected.clone()),
47        // ECR OCI v2 blob upload has no AWS auth header; the path
48        // alone (`/v2/.../blobs/uploads/...`) tells us the route is
49        // ECR. Synthesize a DetectedRequest so dispatch picks the
50        // streaming path. Same special-case the buffered branch
51        // applies on detect_service None (see below).
52        (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        // Buffered path: materialize the body into memory under the
64        // configured cap. `FAKECLOUD_MAX_REQUEST_BODY_BYTES` (default
65        // 1 GiB) caps non-streaming requests; streaming routes have no
66        // cap because nothing materializes the entire body in RAM.
67        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    // Detect service and action
83    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                // A request carrying X-Amz-Target is unambiguously an awsJson
90                // call whose operation we couldn't map to a known service. AWS
91                // answers these with UnknownOperationException; routing them to
92                // the apigateway catch-all below would 404 with a misleading
93                // "Stage not found" instead.
94                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                // OPTIONS requests (CORS preflight) don't carry Authorization headers.
108                // Route them to S3 since S3 is the only REST service that handles CORS.
109                // Note: API Gateway CORS preflight is not fully supported in this emulator
110                // because we can't distinguish between S3 and API Gateway OPTIONS requests
111                // without additional context (in real AWS, they have different domains).
112                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                    // OCI Distribution v2 protocol. Docker CLI / OCI clients
120                    // use Basic auth (not SigV4) and GET /v2/ with no body,
121                    // so this must be matched before the apigateway fallback.
122                    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                    // Unsigned request whose first path segment names an
129                    // existing S3 bucket: an anonymous path-style S3 access
130                    // (e.g. serving a public-read object to a browser). Without
131                    // this it would fall through to the apigateway catch-all
132                    // below and 404 with "Stage not found" (#1707). Authorization
133                    // for the anonymous caller still runs in the IAM block.
134                    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                    // Requests without AWS auth that don't match any service might be
142                    // API Gateway execute API calls (plain HTTP without signatures).
143                    // Route them to apigateway service which will validate if a matching
144                    // API/stage exists. Skip special FakeCloud endpoints (/_*).
145                    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    // Bedrock-agent and bedrock-runtime both send `bedrock` in the SigV4
164    // credential scope, but bedrock-agent has its own service handler.
165    // Disambiguate based on the request path.
166    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    // Amazon DocumentDB shares RDS's Query wire protocol, `rds` SigV4
179    // signing scope, and `rds.<region>.amazonaws.com` endpoint, so a real
180    // `aws-sdk-docdb` request is indistinguishable from `aws-sdk-rds` by
181    // signing name or host alone. The DocumentDB SDK does stamp an
182    // `api/docdb` token into its `user-agent`; use it to route the request
183    // to the dedicated `docdb` handler. (The conformance probe signs the
184    // `docdb` scope directly, so it never reaches this branch.) Requests
185    // without the token stay on `rds`.
186    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    // Amazon Neptune shares RDS's Query wire protocol, `rds` SigV4 signing
196    // scope, and `rds.<region>.amazonaws.com` endpoint, so a real
197    // `aws-sdk-neptune` request is indistinguishable from `aws-sdk-rds` by
198    // signing name or host alone. The Neptune SDK does stamp an
199    // `api/neptune` token into its `user-agent`; use it to route the request
200    // to the dedicated `neptune` handler. (The conformance probe signs the
201    // `neptune` scope directly, so it never reaches this branch.) Requests
202    // without the token stay on `rds`.
203    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    // Look up service
213    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    // Extract region and access key from auth header (or presigned query).
227    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        // Presigned URL: credentials live in the query string.
235        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    // SigV2 presigned URLs (`AWSAccessKeyId` + `Signature` + `Expires` query
241    // parameters) carry the access key outside the SigV4 grammar, so the SigV4
242    // parsers above return None. Recover the key here so a SigV2-presigned
243    // request is attributed to its caller instead of being treated as
244    // anonymous (which would deny it under the object read auth gate).
245    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    // Host-header routing hint: LocalStack-shaped
251    // `<svc>.<region>.localhost.localstack.cloud[:port]`, real-AWS
252    // `<svc>.<region>.amazonaws.com`, and every S3 virtual-hosted variant
253    // of both. Secondary region source and carries the bucket for
254    // virtual-hosted S3 path rewrite.
255    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    // Resolve the caller's principal up front so both SigV4 verification
264    // (which needs the secret) and the service handler (which needs the
265    // identity for GetCallerIdentity and IAM enforcement) share a single
266    // lookup. The root-bypass AKID skips resolution entirely — `test`
267    // credentials have no backing identity and must always pass.
268    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    // Opt-in SigV4 cryptographic verification. Runs before the service
284    // handler so a failing signature never reaches business logic. The
285    // reserved `test*` root identity short-circuits verification to keep
286    // local-dev workflows frictionless.
287    //
288    // A fully anonymous request — no `Authorization` header AND no presigned
289    // credential (SigV4 `X-Amz-Credential` or SigV2 `AWSAccessKeyId`) — carries
290    // no signature to verify, so it must NOT be hard-403'd here even under
291    // `--verify-sigv4`. AWS treats an unsigned request as the anonymous
292    // principal and lets a resource policy / public-read ACL authorize it
293    // (e.g. a public S3 object GET). Let it fall through to the
294    // anonymous-authorization path below, which allows a public resource and
295    // otherwise denies. A request that DID present a credential but failed to
296    // parse is NOT anonymous (`Authorization` non-empty or a presign query
297    // present) and still returns the malformed-signature error below.
298    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                // Bind the buffered request body to the signed
353                // `x-amz-content-sha256`. The sigv4 canonical builder uses that
354                // header value verbatim (it is a signed header) and deliberately
355                // does NOT re-hash the body: for streaming / aws-chunked routes
356                // `body_bytes` is empty at that layer, so re-hashing there would
357                // reject legitimate signed requests (see the
358                // `feedback_sigv4_body_hash_wrong_layer` caveat). S3 rebinds the
359                // body hash in its own write path (`XAmzContentSHA256Mismatch`);
360                // every other service is bound HERE, where the full buffered body
361                // is available. Only a genuine 64-char lowercase-hex digest is
362                // checked — `UNSIGNED-PAYLOAD`, `STREAMING-*`, and presigned
363                // (UNSIGNED-PAYLOAD) requests are skipped, so correct clients are
364                // unaffected. A mismatch means the body was altered after signing;
365                // AWS would then compute a different canonical request, so return
366                // `SignatureDoesNotMatch`.
367                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    // Build path segments. For S3 virtual-hosted-style requests the bucket
444    // lives in the Host header, not the path — prepend it so the S3 handler
445    // sees a uniform path-style request. SigV4 verification above already
446    // ran against the wire path, so this rewrite is signature-safe.
447    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    // For JSON protocol, validate that non-empty bodies are valid JSON
464    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    // Merge query params with form body params for the Query family (awsQuery
478    // and ec2Query share identical form-encoded request bodies).
479    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    // CloudWatch (`monitoring`) advertises awsJson1_0 alongside awsQuery. Its
491    // handlers all read the flat awsQuery param map, so when a client uses the
492    // JSON protocol we flatten the JSON body into that same map, leaving the
493    // handlers unchanged. The handler emits a JSON response for JSON callers.
494    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    // Opt-in IAM identity-policy enforcement. Runs before the service
534    // handler so a deny never reaches business logic. Root principals
535    // (both `test*` bypass AKIDs and the account's IAM root) are exempt,
536    // matching AWS behavior. Services that haven't opted in via
537    // `iam_enforceable()` are transparently skipped — the startup log
538    // lists which services are under enforcement so users always know.
539    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                    // A request can need several authorizations -- one per
547                    // table in a batch, say -- and every one must allow it.
548                    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                            // F3 keys riding on the resolved credential. STS
558                            // populates these at mint time so subsequent
559                            // requests under the credential can be evaluated
560                            // against `aws:MultiFactorAuthPresent`,
561                            // `aws:MultiFactorAuthAge`, `aws:TokenIssueTime`,
562                            // and `aws:FederatedProvider`. IAM user access
563                            // keys carry none of these, matching AWS.
564                            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                                // `aws:MultiFactorAuthAge` is "seconds since
570                                // MFA was asserted" — computed at evaluation
571                                // time from the token issue moment so the
572                                // value increases monotonically as the session
573                                // ages. Only set when the session was actually
574                                // minted with MFA; otherwise the key is
575                                // absent, matching AWS.
576                                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                            // ABAC: populate tag-based condition keys.
590                            // aws:ResourceTag/*
591                            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                            // aws:RequestTag/* + aws:TagKeys
601                            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                            // aws:PrincipalTag/*
611                            condition_context.principal_tags = principal.tags.clone();
612
613                            // Phase 2: fetch the resource-based policy (if
614                            // any) attached to the target resource and
615                            // pass it to the evaluator alongside the
616                            // principal's identity policies. The resource's
617                            // owning account is parsed from the ARN (#381
618                            // multi-account alignment); S3 ARNs have an
619                            // empty account field, so we fall back to the
620                            // server's configured account ID in that case.
621                            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                            // Derive the resource-owning account. Prefer a provider
626                            // lookup (S3 ARNs carry no account, so the bucket's
627                            // owner is resolved from state — without this, account
628                            // A reaching account B's bucket would be mis-read as
629                            // same-account and skip B's bucket-policy requirement,
630                            // bug-audit 2026-05-28, 5.3), then fall back to the
631                            // account embedded in the ARN (SQS/SNS/Lambda/…), then
632                            // to the caller's account for wildcard / unscoped
633                            // actions (ListQueues, GetCallerIdentity).
634                            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                            // SCP ceiling: resolve the inherited SCP chain
646                            // for this principal (management accounts and
647                            // service-linked roles come back as `None`, in
648                            // which case the evaluator treats the layer as
649                            // absent). Audit breadcrumbs emitted by the
650                            // resolver itself, not here.
651                            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                                    // Real AWS includes an "Encoded
679                                    // authorization failure message" suffix
680                                    // on AccessDeniedException — an opaque
681                                    // base64+zlib JSON blob that the caller
682                                    // can pass to STS
683                                    // `DecodeAuthorizationMessage` to
684                                    // recover the structured deny reason
685                                    // (action, principal, matched
686                                    // statements, condition context). We
687                                    // produce the same blob inline so
688                                    // existing tooling that decodes deny
689                                    // reasons works against fakecloud.
690                                    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                                // Soft mode: audit log already emitted; fall
727                                // through to the handler.
728                            }
729                        }
730                    } else {
731                        // Service opted in via `iam_enforceable()` but its
732                        // `iam_action_for` returned no `IamAction` for this
733                        // specific operation (e.g. S3's `s3_detect_action`
734                        // has `_ => return None` arms for unrecognized
735                        // sub-resources). Under strict enforcement that must
736                        // fail closed: an operation we cannot map to an IAM
737                        // action cannot be authorized, so serving it would be
738                        // a fail-open bypass of `--iam strict`. Deny by
739                        // default. In soft mode we preserve the historical
740                        // warn-and-allow so an incomplete mapping surfaces
741                        // during rollout without blocking traffic.
742                        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                        // Soft mode: audit log emitted; fall through to the
763                        // handler.
764                    }
765                }
766            } else if aws_request.access_key_id.is_none() {
767                // Truly anonymous (unsigned) caller — no Authorization header at
768                // all. No identity policies exist, so authorization rests
769                // entirely on the resource policy (a bucket policy granting
770                // `Principal:"*"`) and public-read ACLs — mirroring AWS, which
771                // denies anonymous requests unless the resource is explicitly
772                // made public. Without this an anonymous request that reached an
773                // iam_enforceable service in enforcement mode would bypass
774                // authorization entirely.
775                //
776                // A request that carried an Authorization header but whose
777                // credential did not resolve (principal `None` with
778                // `access_key_id` `Some`) is intentionally left alone here: with
779                // SigV4 verification off, fakecloud does not reject unverified
780                // signed requests, and turning them into anonymous denials would
781                // change long-standing behavior.
782                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                        // An explicit Deny in the resource policy always wins, even
807                        // over a public-read ACL — matching AWS's Deny-overrides
808                        // precedence. Collapsing the decision to a bool and ORing the
809                        // ACL let a public ACL override an explicit anonymous Deny.
810                        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                            // Soft mode: audit log emitted; fall through to the handler.
841                        }
842                    }
843                } else {
844                    // Anonymous request to an iam_enforceable service whose
845                    // operation has no IamAction mapping. Mirror the signed
846                    // branch above: an operation we cannot map to an IAM action
847                    // cannot be authorized, so serving it would be a fail-open
848                    // bypass of `--iam strict`. Deny by default under strict;
849                    // soft mode warns and falls through.
850                    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                    // `Vary` combines, so two entries for it must both survive
926                    // (S3 adds its CORS value to an error that may carry its
927                    // own) — but an identical value is not worth repeating.
928                    // Everything else replaces, so a service can still override
929                    // a header this builder already set.
930                    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/// Configuration passed to the dispatch handler.
950#[derive(Clone)]
951pub struct DispatchConfig {
952    pub region: String,
953    pub account_id: String,
954    /// Whether to cryptographically verify SigV4 signatures on incoming
955    /// requests. Wired through from `--verify-sigv4` /
956    /// `FAKECLOUD_VERIFY_SIGV4`. Off by default.
957    pub verify_sigv4: bool,
958    /// IAM policy evaluation mode. Wired through from `--iam` /
959    /// `FAKECLOUD_IAM`. Defaults to [`IamMode::Off`]. Actual evaluation is
960    /// added in a later batch; today this field is plumbed but never
961    /// consulted.
962    pub iam_mode: IamMode,
963    /// Resolves access key IDs to their secrets and owning principals.
964    /// Required when `verify_sigv4` or `iam_mode != Off`. When `None`, both
965    /// features gracefully degrade to off-by-default behavior.
966    pub credential_resolver: Option<Arc<dyn CredentialResolver>>,
967    /// Evaluates IAM identity policies for a resolved principal + action.
968    /// Required when `iam_mode != Off`. When `None`, enforcement silently
969    /// degrades to off even if `iam_mode` is set.
970    pub policy_evaluator: Option<Arc<dyn IamPolicyEvaluator>>,
971    /// Resolves resource-based policies (S3 bucket policies in the
972    /// initial rollout) to hand to the evaluator alongside the
973    /// principal's identity policies. `None` means the server was
974    /// started without any resource-policy-owning service registered;
975    /// dispatch then behaves as if no resource policy is attached to
976    /// any resource, identical to the Phase 1 behavior.
977    pub resource_policy_provider: Option<Arc<dyn ResourcePolicyProvider>>,
978    /// Resolves the ordered SCP chain that applies to a principal's
979    /// account (root-OU first, account-direct last). `None` means no
980    /// organizations resolver has been registered — SCPs never gate
981    /// any request in that case. Off-by-default matches the Batch 4
982    /// contract: zero behavior change until a user calls
983    /// `CreateOrganization` and the resolver is wired.
984    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    /// Minimal constructor for tests and call sites that don't care about the
1025    /// opt-in security features.
1026    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
1040/// The path an S3 request is routed on: for virtual-hosted-style the bucket
1041/// comes from the Host header and is prepended, unless the client already put
1042/// it in the path (`PUT /<bucket>` / `PUT /<bucket>/key` against a
1043/// virtual-hosted host). Path-style requests pass through unchanged.
1044///
1045/// Shared with [`streaming_route`] on purpose. The streaming gate has to agree
1046/// with routing about where the bucket ends and the key begins; when the two
1047/// derived it separately they drifted, and a request the router read as a
1048/// bucket-level operation was dispatched unbuffered, so the handler and IAM
1049/// enforcement saw an empty body.
1050///
1051/// Under virtual-hosted addressing the whole wire path is the object key, as
1052/// on real S3, so the bucket is always prefixed: on
1053/// `docs.s3.<region>.amazonaws.com`, `GET /docs/intro.html` addresses the key
1054/// `docs/intro.html`, not `intro.html`.
1055fn 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
1066/// Extract the 12-digit account ID segment from an AWS ARN.
1067///
1068/// ARNs follow `arn:<partition>:<service>:<region>:<account>:<resource>`.
1069/// Identifies routes that opt into streaming request bodies. Returns
1070/// `Some((service, action_hint))` when the dispatch path should hand
1071/// the raw body to the service handler unbuffered, otherwise `None`
1072/// for the default buffered path. The handler reads the stream via
1073/// [`crate::service::AwsRequest::take_body_stream`].
1074///
1075/// Streaming-eligible routes today:
1076///
1077/// * `s3` PUT object — `PUT /<bucket>/<key>` with a SigV4 (or
1078///   presigned) auth header. Covers PutObject, UploadPart, and
1079///   UploadPartCopy. The S3 service spills to disk via
1080///   [`fakecloud_persistence::BodySource::File`] when the stream is
1081///   present.
1082/// * `ecr` OCI Distribution v2 blob upload — `PATCH` and `PUT` on
1083///   `/v2/{name}/blobs/uploads/{uuid}`. The ECR service spools the
1084///   stream into a per-upload temp file before computing the digest.
1085fn 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    // ECR OCI v2 blob upload (PATCH chunk + final PUT).
1092    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    // S3 PutObject / UploadPart / UploadPartCopy. Detect either via
1100    // SigV4 service field in the Authorization header OR via a SigV4
1101    // presigned URL (X-Amz-Credential .../s3/...) OR a SigV2 presigned
1102    // URL (AWSAccessKeyId + Signature + Expires query parameters).
1103    if method == http::Method::PUT {
1104        // An object upload needs a bucket AND a key. Resolve the request the
1105        // way routing will (bucket from the Host header for virtual-hosted,
1106        // straight from the path otherwise), then split it the way the request
1107        // builder builds `path_segments` -- on '/', dropping empty segments.
1108        // Anything that leaves no key is a bucket-level operation:
1109        // `PUT /<bucket>`, `PUT /<bucket>/` (the form the AWS SDKs actually
1110        // send for CreateBucket), `PUT /<bucket>//`, and the virtual-hosted
1111        // equivalents. Streaming those left the body unbuffered, so everything
1112        // that runs before the handler -- IAM enforcement above all -- saw an
1113        // empty body and could not read `CreateBucketConfiguration` (no
1114        // `aws:RequestTag` from a create-time tag set, for one).
1115        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
1145/// Default request-body buffering cap. fakecloud reads the entire
1146/// request body into memory before handing it to a service handler,
1147/// so this ceiling caps RAM usage per in-flight request.
1148///
1149/// Default 1 GiB — comfortably above legitimate single S3 PutObject
1150/// payloads (AWS recommends multipart above ~100 MiB) and each
1151/// multipart part dispatches through here separately. Override with
1152/// `FAKECLOUD_MAX_REQUEST_BODY_BYTES` (decimal bytes) when running
1153/// stress tests that push past the default.
1154const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024 * 1024;
1155
1156/// The server-wide buffered request body cap in bytes, from
1157/// `FAKECLOUD_MAX_REQUEST_BODY_BYTES` (default 1 GiB). Public so buffered
1158/// sub-proxies (e.g. the CloudFront viewer data plane) apply the SAME cap as
1159/// direct traffic rather than an inconsistent limit of their own.
1160pub 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
1171/// For the cross-account decision in IAM enforcement, the "resource
1172/// account" is the ARN's account segment. Some services (notably S3)
1173/// produce ARNs with an empty account field — for those we return
1174/// `None` and let the caller fall back to the server's configured
1175/// account ID. Malformed or non-ARN strings also return `None`.
1176fn 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    // Resource segment must exist (parts.next().is_some()) for the ARN
1186    // to be well-formed, but we don't consume its value here.
1187    parts.next()?;
1188    if account.is_empty() {
1189        None
1190    } else {
1191        Some(account.to_string())
1192    }
1193}
1194
1195/// Whether the request's `user-agent` (or `x-amz-user-agent`) carries the
1196/// `api/neptune` SDK-metadata token the `aws-sdk-neptune` client stamps in.
1197/// Used to disambiguate Neptune from RDS, which share the `rds` SigV4 scope
1198/// and endpoint. Matches the `api/neptune` token that AWS SDKs emit (a
1199/// leading `api/neptune` optionally followed by `#`/`/` and a version).
1200fn 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
1215/// Extract region from User-Agent header suffix `region/<region>`.
1216/// Whether the request's `user-agent` (or `x-amz-user-agent`) carries the
1217/// `api/docdb` SDK-metadata token the `aws-sdk-docdb` client stamps in. Used
1218/// to disambiguate DocumentDB from RDS, which share the `rds` SigV4 scope
1219/// and endpoint. Matches the `api/docdb` token that AWS SDKs emit (a leading
1220/// `api/docdb` optionally followed by `#`/`/` and a version).
1221fn 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        // awsQuery services (SQS, SNS, IAM, STS, RDS, ELBv2, CloudWatch,
1268        // AutoScaling, ...) share the `<ErrorResponse>` envelope.
1269        AwsProtocol::Query => {
1270            fakecloud_aws::error::xml_error_response(status, code, message, request_id)
1271        }
1272        // EC2 uses the distinct ec2Query error envelope
1273        // (`<Response><Errors><Error>...</Errors><RequestID>`). Only EC2 is
1274        // classified `Ec2Query`, so the other Query-protocol services above are
1275        // untouched.
1276        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    // S3 (and other REST-XML services) place the error code in
1297    // `x-amz-error-code` so HEAD responses — which HTTP forbids from
1298    // carrying a body — still surface the code. AWS SDKs read this header
1299    // when the body is empty. Emit it on every error response so HEAD,
1300    // OPTIONS, and any client that strips the body still see the code.
1301    // Backend errors regularly include newlines (multi-line stderr from
1302    // docker/podman/etc.); HTTP header values reject control characters,
1303    // so sanitize before insertion or the builder rejects the response
1304    // and the connection drops.
1305    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        // Builder only fails if a header is invalid; we sanitized the two
1320        // we control, so the remaining ones (content-type, request id) are
1321        // ASCII and safe. This fallback exists purely so we never panic.
1322        Response::new(Body::empty())
1323    })
1324}
1325
1326/// Strip characters that HTTP header values reject (control bytes, CR/LF/TAB)
1327/// and truncate to a length that AWS SDKs handle cleanly. Backend tools
1328/// (docker, podman, kubectl, …) emit multi-line stderr, and forwarding that
1329/// raw into `x-amz-error-message` previously panicked the dispatcher.
1330fn 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        // Header values forbid CR, LF, and other control bytes (RFC 9110).
1338        // Replace with a single space so multi-line messages stay readable.
1339        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
1350/// Build the [`ConditionContext`] passed to the IAM evaluator for one
1351/// request. Populates the 10 global condition keys from the resolved
1352/// principal + the HTTP request. Service-specific keys are deferred to
1353/// a follow-up batch and left empty.
1354/// For an unsigned request that no other detection rule claimed, return the
1355/// bucket name when the first path segment names an existing S3 bucket.
1356///
1357/// fakecloud serves every service from one endpoint, so an anonymous
1358/// path-style S3 request (`GET /bucket/key`, no SigV4) is indistinguishable
1359/// from an API Gateway execute-api call by headers alone. Bucket existence is
1360/// the disambiguator: if the segment is a real bucket, route to S3; otherwise
1361/// fall through to the apigateway catch-all. Uses the already-wired
1362/// `resource_policy_provider`, which resolves S3 bucket ownership from state
1363/// (`Some` => the bucket exists). Returns `None` when no provider is wired.
1364/// Recover the access key from a SigV2 presigned URL. AWS SigV2 presigning
1365/// puts the key in the `AWSAccessKeyId` query parameter alongside `Signature`
1366/// and `Expires`; all three must be present for the URL to be a SigV2 presign.
1367/// Returns None for SigV4 presigns (which use `X-Amz-Credential`) or unsigned
1368/// requests.
1369fn 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
1377/// True when `s` is a 64-character lowercase-hex SHA-256 digest — the form a
1378/// signed `x-amz-content-sha256` payload hash takes. Distinguishes a real body
1379/// hash from the `UNSIGNED-PAYLOAD` / `STREAMING-*` markers (shorter and
1380/// containing non-hex characters), so only genuine hashes are re-bound to the
1381/// buffered body.
1382fn 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
1386/// Lowercase-hex SHA-256 of `bytes`, used to re-bind a signed
1387/// `x-amz-content-sha256` to the buffered request body for non-S3 services.
1388fn 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        // F3 keys: populated from the caller's session context when STS
1426        // mints credentials with MFA / SAML / OIDC / VPC-endpoint hints.
1427        // Default-None here so tests/dispatch sites that don't set them
1428        // safe-fail any policy referencing them — matching AWS for keys
1429        // that aren't asserted.
1430        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
1445/// `aws:username` is only set for IAM users, matching AWS. For assumed
1446/// roles, federated users, root, and unknown principals the key is
1447/// absent — operators that reference it without `IfExists` safe-fail.
1448fn 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    // Strip any IAM path prefix; bare username is the last segment.
1454    Some(after.rsplit('/').next().unwrap_or(after).to_string())
1455}
1456
1457/// AWS's `aws:PrincipalType` uses PascalCase identifiers, distinct from
1458/// the lowercase ones [`PrincipalType::as_str`] returns for ARNs.
1459fn 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
1469/// Best-effort detection of TLS-terminated requests. Direct HTTPS
1470/// connections are not yet supported by the fakecloud server (it speaks
1471/// plain HTTP), so the only signal is an `x-forwarded-proto: https`
1472/// header set by an upstream proxy. Anything else evaluates to `false`,
1473/// which matches the typical local-dev setup.
1474fn 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
1492/// Whether a (possibly percent-encoded) `/tags/{resourceArn}` label is the ARN
1493/// of a Bedrock Agents runtime session (`...:session/<id>`).
1494fn 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
1502/// Which Bedrock Agents handler a `bedrock`-scoped request belongs to:
1503/// `bedrock-agent-runtime` or `bedrock-agent` for their path families, `None`
1504/// for any other Bedrock (bedrock-runtime / control plane) path. Runtime and
1505/// control plane share `/agents/...` and `/flows/...` prefixes, so the split
1506/// is by path shape and, where the shapes coincide, by method.
1507fn 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", _, ..]  // InvokeAgent
1532            | ["", "flows", _, "executions"] // ListFlowExecutions
1533            | ["", "flows", _, "aliases", _, "executions", ..] // flow executions
1534            | ["", "knowledgebases", _, "retrieve"] // Retrieve
1535            | ["", "retrieveAndGenerate"]
1536            | ["", "retrieveAndGenerateStream"]
1537            | ["", "optimize-prompt"]
1538            | ["", "sessions", ..]
1539            | ["", "invocations", ..]
1540            | ["", "generate-query"]
1541            | ["", "rerank"]
1542    ) || (
1543        // InvokeFlow is POST /flows/{flow}/aliases/{alias}; the same path
1544        // under GET / PUT / DELETE is the control plane's flow-alias CRUD.
1545        *method == http::Method::POST && matches!(segs.as_slice(), ["", "flows", _, "aliases", _])
1546    ) || (
1547        // InvokeInlineAgent is POST /agents/{sessionId}; the control plane's
1548        // PrepareAgent is POST /agents/{agentId}/ (trailing slash).
1549        *method == http::Method::POST
1550            && matches!(segs.as_slice(), ["", "agents", id] if !id.is_empty())
1551    ) || path
1552        // Tagging is shared: a runtime resource (a session) is tagged through
1553        // the runtime, an agent/flow/knowledge-base resource through the
1554        // control plane.
1555        .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        // Without the env override, the cap defaults to 1 GiB. The
1647        // public function caches via OnceLock so only the first call
1648        // in the process matters; we assert the constant directly.
1649        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        // AWSAccessKeyId alone (e.g. a stray query param) is not a SigV2
1667        // presign and must not be treated as a credential.
1668        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        // A genuine 64-char lowercase-hex digest is a bindable body hash.
1688        assert!(is_hex_sha256(&sha256_hex_lower(b"hello")));
1689        assert!(is_hex_sha256(
1690            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1691        ));
1692        // The SigV4 payload markers are NOT hashes and must be skipped.
1693        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        // Wrong length or non-lowercase-hex characters are rejected.
1697        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        // Empty input -> the well-known SHA-256 of "".
1706        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        // S3 ARNs have both region and account empty.
1818        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    /// Regression for issue #1539: multi-line backend errors (e.g. podman
2011    /// stderr) used to panic the dispatcher when stuffed into the
2012    /// `x-amz-error-message` HTTP header. The response must build cleanly
2013    /// and the header value must not contain control characters.
2014    #[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        // Answers only for ARNs the S3 policy provider's bucket parser reads
2080        // (`arn:aws:s3:::<bucket>`), so a probe the real provider would not
2081        // understand misses here too.
2082        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        // `PUT /bucket` (no trailing key) is CreateBucket — must NOT
2156        // hit the streaming path.
2157        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        // The wire path is the whole key on a virtual-hosted request, so a key
2176        // whose first segment equals the bucket name must keep it.
2177        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        // The AWS SDKs send CreateBucket as `PUT /<bucket>/`. The trailing
2187        // slash must not make it look like an object upload: the body carries
2188        // `CreateBucketConfiguration` (location constraint, tag set) and has
2189        // to be buffered before IAM enforcement reads it.
2190        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        // Routing filters empty path segments, so `PUT /<bucket>//` is a
2200        // bucket-level operation, not an object upload with a "/" key. The
2201        // streaming gate has to agree, or the body goes unbuffered for a
2202        // request the handler reads as CreateBucket / PutBucketTagging.
2203        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        // A real object key that ends in '/' (a directory marker) still
2218        // streams -- the gate rejects an absent key, not a trailing slash.
2219        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        // Virtual-hosted PUT has no bucket in the URL path (`/<key>`),
2241        // so the slash check used for path-style would reject it. The
2242        // Host parser confirms this is virtual-hosted S3 and the key
2243        // flows through the streaming dispatch.
2244        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        // Under virtual-hosted addressing the whole path is the key, as on
2253        // real S3: `PUT /my-bucket` on `Host: my-bucket.s3...` uploads the
2254        // object `my-bucket`, not a CreateBucket, so every one of these
2255        // streams.
2256        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        // `PUT /` against a virtual-hosted Host = CreateBucket, which
2273        // is handled buffered. Empty path-after-slash must short-circuit.
2274        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        // Same path shape but the SigV4 service is lambda — must not
2332        // wire the streaming dispatch (Lambda has its own buffered path).
2333        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}