Skip to main content

fakecloud_core/
service.rs

1use async_trait::async_trait;
2use bytes::Bytes;
3use http::{HeaderMap, Method, StatusCode};
4use md5::{Digest, Md5};
5use parking_lot::Mutex;
6use std::collections::{BTreeMap, HashMap};
7use std::path::PathBuf;
8
9use crate::auth::Principal;
10
11/// Streaming request body kept alongside the buffered `body: Bytes`. Set
12/// by dispatch only for routes that opt into streaming (S3 PutObject /
13/// UploadPart, ECR OCI blob upload PATCH/PUT). Service handlers call
14/// [`AwsRequest::take_body_stream`] to consume the raw stream without
15/// buffering the entire payload into memory; non-streaming services
16/// keep using `req.body` (which is empty `Bytes` for streaming routes).
17pub type RequestBodyStream = axum::body::Body;
18
19/// A parsed AWS request.
20pub struct AwsRequest {
21    pub service: String,
22    pub action: String,
23    pub region: String,
24    pub account_id: String,
25    pub request_id: String,
26    pub headers: HeaderMap,
27    pub query_params: HashMap<String, String>,
28    /// Buffered request body. For streaming routes this is `Bytes::new()`
29    /// and the raw body is available via [`AwsRequest::take_body_stream`].
30    pub body: Bytes,
31    /// Raw streaming body, populated only for streaming routes. Wrapped
32    /// in a Mutex so the per-service handler can `.take()` ownership
33    /// behind the shared `&AwsRequest` reference threaded through the
34    /// call chain.
35    pub body_stream: Mutex<Option<RequestBodyStream>>,
36    /// The URI path split on `/` (empty segments dropped), each segment
37    /// percent-decoded exactly once per the Smithy `@httpLabel` rules -- see
38    /// [`crate::path`]. Handlers must not decode these again.
39    pub path_segments: Vec<String>,
40    /// The raw (undecoded) URI path, before splitting into segments.
41    pub raw_path: String,
42    /// The raw URI query string (everything after `?`), preserving repeated keys.
43    pub raw_query: String,
44    pub method: Method,
45    /// Whether this request came via Query (form-encoded) or JSON protocol.
46    pub is_query_protocol: bool,
47    /// The access key ID from the SigV4 Authorization header, if present.
48    pub access_key_id: Option<String>,
49    /// The resolved caller identity. `None` when the credential is unknown
50    /// or the caller used the reserved root-bypass credentials. Populated
51    /// by dispatch via the configured [`crate::auth::CredentialResolver`]
52    /// so service handlers can make identity-based decisions (e.g.
53    /// `GetCallerIdentity`, IAM enforcement) without re-parsing the
54    /// Authorization header.
55    pub principal: Option<Principal>,
56}
57
58impl std::fmt::Debug for AwsRequest {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        f.debug_struct("AwsRequest")
61            .field("service", &self.service)
62            .field("action", &self.action)
63            .field("region", &self.region)
64            .field("account_id", &self.account_id)
65            .field("request_id", &self.request_id)
66            .field("headers", &self.headers)
67            .field("query_params", &self.query_params)
68            .field("body_len", &self.body.len())
69            .field(
70                "body_stream",
71                &self.body_stream.lock().as_ref().map(|_| "<stream>"),
72            )
73            .field("path_segments", &self.path_segments)
74            .field("raw_path", &self.raw_path)
75            .field("raw_query", &self.raw_query)
76            .field("method", &self.method)
77            .field("is_query_protocol", &self.is_query_protocol)
78            .field("access_key_id", &self.access_key_id)
79            .field("principal", &self.principal)
80            .finish()
81    }
82}
83
84impl AwsRequest {
85    /// Parse the request body as JSON, returning `Value::Null` on failure.
86    pub fn json_body(&self) -> serde_json::Value {
87        serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
88    }
89
90    /// Consume the streaming body if this request was dispatched as
91    /// streaming. Returns `None` for buffered requests; the buffered
92    /// body is available via [`AwsRequest::body`]. Calling this twice
93    /// returns `None` on the second call.
94    pub fn take_body_stream(&self) -> Option<RequestBodyStream> {
95        self.body_stream.lock().take()
96    }
97
98    /// All values supplied for a repeated `@httpQuery` parameter, in wire
99    /// order. [`AwsRequest::query_params`] is a `HashMap`, so `?a=1&a=2` keeps
100    /// only `2` there; this re-parses [`AwsRequest::raw_query`] to recover every
101    /// occurrence. Use it for list-style query params (filters, ids) where a
102    /// client legitimately repeats the key. Returns an empty vec when the key
103    /// is absent.
104    pub fn query_param_all(&self, key: &str) -> Vec<String> {
105        crate::protocol::form_urlencoded_pairs(&self.raw_query)
106            .into_iter()
107            .filter_map(|(k, v)| (k == key).then_some(v))
108            .collect()
109    }
110}
111
112/// Drain a streaming request body into a single [`Bytes`] buffer with no
113/// upper bound. Used by handlers that legitimately need the whole payload
114/// in memory (small JSON-shaped requests that happened to land on a
115/// streaming route, e.g. ECR `mount` PUT with no body). Heavy uploads
116/// (S3 PutObject / UploadPart, ECR blob PATCH/PUT) take the streaming
117/// spool path via [`spool_request_stream`] instead. The dispatch-level
118/// cap (`FAKECLOUD_MAX_REQUEST_BODY_BYTES`) does not apply to streaming
119/// routes; this helper exists so a service handler that knows the
120/// payload is small can buffer without dragging in `axum` itself.
121pub async fn drain_request_stream(stream: RequestBodyStream) -> Result<Bytes, AwsServiceError> {
122    use http_body_util::BodyExt;
123    match stream.collect().await {
124        Ok(c) => Ok(c.to_bytes()),
125        Err(e) => Err(stream_error_to_aws(&e.to_string())),
126    }
127}
128
129fn stream_error_to_aws(msg: &str) -> AwsServiceError {
130    // Hyper / axum surface `body limit exceeded` with a
131    // payload-too-large variant. Everything else (connection
132    // reset, malformed chunked encoding, premature EOF) maps
133    // to a 400 BadRequest so callers can distinguish.
134    let too_large = msg.to_ascii_lowercase().contains("limit");
135    let (status, code, message) = if too_large {
136        (
137            StatusCode::PAYLOAD_TOO_LARGE,
138            "RequestEntityTooLarge",
139            "Streaming request body exceeded the configured limit",
140        )
141    } else {
142        (
143            StatusCode::BAD_REQUEST,
144            "MalformedRequestBody",
145            "Failed to read streaming request body",
146        )
147    };
148    AwsServiceError::aws_error(status, code, message)
149}
150
151/// Outcome of spooling a streaming request body to disk: the path of the
152/// freshly created tempfile, the total byte count, and the MD5 hash of
153/// the bytes (lowercase hex, the form S3 uses for `ETag`).
154///
155/// The caller owns the file and is responsible for either consuming it
156/// (passing the [`PathBuf`] into a `BodySource::File` handed to a store)
157/// or unlinking it. Returning the file path instead of a handle lets the
158/// downstream store rename the file directly, which is the whole point —
159/// in disk-mode S3 a 1 GiB upload performs zero in-RAM copies of the
160/// payload.
161#[derive(Debug)]
162pub struct SpooledBody {
163    pub path: PathBuf,
164    pub size: u64,
165    pub md5_hex: String,
166    /// The raw MD5 digest `md5_hex` spells out.
167    pub md5: [u8; 16],
168    /// Trailing headers that followed the payload: the `aws-chunked` trailer
169    /// section (e.g. `x-amz-checksum-crc32:<base64>`) and any HTTP/1.1 chunked
170    /// trailers. Names are lowercased, values trimmed; the trailer signature
171    /// line of a signed streaming upload is not included.
172    pub trailers: Vec<(String, String)>,
173}
174
175/// Incremental decoder for the `aws-chunked` content-encoding that modern AWS
176/// S3 clients (aws-cli, boto3 >= 1.36, aws-crt) apply by default to PutObject /
177/// UploadPart bodies when they send `x-amz-content-sha256:
178/// STREAMING-AWS4-HMAC-SHA256-PAYLOAD` (or `STREAMING-UNSIGNED-PAYLOAD-TRAILER`).
179///
180/// The wire format wraps the real payload in application-layer frames:
181/// `<hex-size>[;chunk-signature=<hex>]\r\n<data>\r\n` repeated, terminated by a
182/// `0`-size chunk, then optional `x-amz-trailer` lines, then a final `\r\n`.
183/// hyper only strips HTTP `Transfer-Encoding: chunked`, NOT this
184/// `Content-Encoding: aws-chunked` framing — so without decoding, the size /
185/// signature lines and trailers get stored as the object's bytes (silent
186/// corruption + a wrong ETag). This is fed incrementally because network frames
187/// do not align to chunk boundaries. Trailer lines (`x-amz-checksum-<algo>:<v>`)
188/// are collected so the service can verify the trailing checksum against the
189/// decoded bytes; see [`AwsChunkedDecoder::trailers`].
190#[derive(Default)]
191pub struct AwsChunkedDecoder {
192    state: ChunkState,
193    line: Vec<u8>,
194    remaining: usize,
195    done: bool,
196    trailers: Vec<(String, String)>,
197}
198
199#[derive(Default, PartialEq)]
200enum ChunkState {
201    #[default]
202    Header,
203    Data,
204    AfterData,
205    Trailer,
206}
207
208/// A malformed `aws-chunked` chunk-size line (non-hex length).
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub struct MalformedChunk;
211
212impl AwsChunkedDecoder {
213    /// Feed a network frame; returns the decoded payload bytes it yielded.
214    /// Errors only on a malformed chunk-size line.
215    pub fn feed(&mut self, input: &[u8]) -> Result<Vec<u8>, MalformedChunk> {
216        let mut out = Vec::new();
217        let mut i = 0;
218        while i < input.len() && !self.done {
219            match self.state {
220                ChunkState::Data => {
221                    let take = self.remaining.min(input.len() - i);
222                    out.extend_from_slice(&input[i..i + take]);
223                    i += take;
224                    self.remaining -= take;
225                    if self.remaining == 0 {
226                        self.state = ChunkState::AfterData;
227                    }
228                }
229                ChunkState::AfterData => {
230                    // Consume the inter-chunk CRLF; next byte after \n is a header.
231                    while i < input.len() {
232                        let b = input[i];
233                        i += 1;
234                        if b == b'\n' {
235                            self.state = ChunkState::Header;
236                            break;
237                        }
238                    }
239                }
240                ChunkState::Header | ChunkState::Trailer => {
241                    let is_header = self.state == ChunkState::Header;
242                    while i < input.len() {
243                        let b = input[i];
244                        i += 1;
245                        if b == b'\n' {
246                            let line = std::mem::take(&mut self.line);
247                            if is_header {
248                                // size is the hex up to a `;` extension or EOL.
249                                let hex_part: &[u8] =
250                                    line.split(|&c| c == b';').next().unwrap_or(&[]);
251                                let hex = std::str::from_utf8(hex_part)
252                                    .map_err(|_| MalformedChunk)?
253                                    .trim();
254                                let size =
255                                    usize::from_str_radix(hex, 16).map_err(|_| MalformedChunk)?;
256                                if size == 0 {
257                                    self.state = ChunkState::Trailer;
258                                } else {
259                                    self.remaining = size;
260                                    self.state = ChunkState::Data;
261                                }
262                            } else if line.is_empty() {
263                                // Blank line ends the trailer section.
264                                self.done = true;
265                            } else {
266                                self.push_trailer(&line);
267                            }
268                            break;
269                        } else if b != b'\r' {
270                            self.line.push(b);
271                        }
272                    }
273                }
274            }
275        }
276        Ok(out)
277    }
278
279    /// Record one `name:value` trailer line. The signature of a signed
280    /// streaming upload (`x-amz-trailer-signature`) authenticates the trailer
281    /// rather than describing the payload, so it is not kept.
282    fn push_trailer(&mut self, line: &[u8]) {
283        let text = String::from_utf8_lossy(line);
284        let Some((name, value)) = text.split_once(':') else {
285            return;
286        };
287        let name = name.trim().to_ascii_lowercase();
288        if name.is_empty() || name == "x-amz-trailer-signature" {
289            return;
290        }
291        self.trailers.push((name, value.trim().to_string()));
292    }
293
294    /// The trailer headers read after the terminating zero-size chunk.
295    pub fn trailers(&self) -> &[(String, String)] {
296        &self.trailers
297    }
298
299    /// Consume the decoder, returning its trailer headers.
300    pub fn into_trailers(self) -> Vec<(String, String)> {
301        self.trailers
302    }
303}
304
305/// A fully decoded `aws-chunked` body held in memory.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub struct DecodedAwsChunked {
308    pub payload: Vec<u8>,
309    pub trailers: Vec<(String, String)>,
310}
311
312/// Decode an already-buffered `aws-chunked` body (the framing a streaming
313/// payload carries when it reaches a handler through the buffered path, e.g.
314/// S3 `PutObjectAnnotation`).
315pub fn decode_aws_chunked(body: &[u8]) -> Result<DecodedAwsChunked, MalformedChunk> {
316    let mut decoder = AwsChunkedDecoder::default();
317    let payload = decoder.feed(body)?;
318    Ok(DecodedAwsChunked {
319        payload,
320        trailers: decoder.into_trailers(),
321    })
322}
323
324/// Whether a request body carries the `aws-chunked` content-encoding (so the
325/// spool path must decode the framing). True when `Content-Encoding` lists
326/// `aws-chunked`, or `x-amz-content-sha256` is a `STREAMING-…` marker — both of
327/// which default modern S3 clients (aws-cli, boto3 >= 1.36, aws-crt) set.
328pub fn is_aws_chunked(headers: &http::HeaderMap) -> bool {
329    headers
330        .get("content-encoding")
331        .and_then(|v| v.to_str().ok())
332        .is_some_and(|v| {
333            v.split(',')
334                .any(|t| t.trim().eq_ignore_ascii_case("aws-chunked"))
335        })
336        || headers
337            .get("x-amz-content-sha256")
338            .and_then(|v| v.to_str().ok())
339            .is_some_and(|v| v.starts_with("STREAMING-"))
340}
341
342/// Strip the `aws-chunked` token from a client `Content-Encoding` so the stored
343/// object metadata reflects what AWS keeps (it consumes `aws-chunked` as a
344/// transfer detail; any remaining real encoding such as `gzip` is preserved).
345/// Returns `None` when nothing meaningful remains.
346pub fn strip_aws_chunked_encoding(content_encoding: Option<&str>) -> Option<String> {
347    let ce = content_encoding?;
348    let kept: Vec<&str> = ce
349        .split(',')
350        .map(|t| t.trim())
351        .filter(|t| !t.is_empty() && !t.eq_ignore_ascii_case("aws-chunked"))
352        .collect();
353    if kept.is_empty() {
354        None
355    } else {
356        Some(kept.join(", "))
357    }
358}
359
360/// Stream a request body to a tempfile on disk while computing its MD5
361/// and length on the fly. The body is **never** materialized into a
362/// single `Bytes` buffer; chunks flow from hyper -> Tokio file in
363/// constant memory. A 1 GiB PutObject moves through this function with
364/// peak resident memory bounded by hyper's per-frame buffer.
365///
366/// `dir` controls where the tempfile lands. S3 callers point this at
367/// the S3 object root so the eventual rename into the final storage
368/// path stays on the same filesystem and is a metadata-only move.
369/// Memory-mode callers can pass `None` for the system temp dir; the
370/// memory store reads the file back into bytes and unlinks it.
371///
372/// `aws_chunked` decodes the `Content-Encoding: aws-chunked` application-layer
373/// framing that default modern S3 clients apply (see [`AwsChunkedDecoder`]), so
374/// the spooled bytes, MD5/ETag, and size reflect the real payload — not the
375/// chunk-size/signature framing. Non-S3 callers (and raw `UNSIGNED-PAYLOAD`
376/// uploads) pass `false` and stream verbatim.
377pub async fn spool_request_stream(
378    stream: RequestBodyStream,
379    dir: Option<&std::path::Path>,
380    aws_chunked: bool,
381) -> Result<SpooledBody, AwsServiceError> {
382    spool_request_stream_observed(stream, dir, aws_chunked, &mut |_| {}).await
383}
384
385/// [`spool_request_stream`], additionally handing every decoded payload chunk
386/// to `observer` in order. Lets a caller fold extra digests (S3's flexible
387/// checksums) into the same single pass over the body instead of re-reading
388/// the spooled file.
389pub async fn spool_request_stream_observed(
390    stream: RequestBodyStream,
391    dir: Option<&std::path::Path>,
392    aws_chunked: bool,
393    observer: &mut (dyn FnMut(&[u8]) + Send),
394) -> Result<SpooledBody, AwsServiceError> {
395    use http_body_util::BodyExt;
396    use tokio::io::AsyncWriteExt;
397
398    let dir = dir.map(|d| d.to_path_buf());
399    if let Some(d) = dir.as_ref() {
400        // Best-effort create; an existing dir is fine.
401        let _ = tokio::fs::create_dir_all(d).await;
402    }
403
404    let mut builder = tempfile::Builder::new();
405    builder.prefix("fc-spool-");
406    let named = match dir.as_ref() {
407        Some(d) => builder.tempfile_in(d),
408        None => builder.tempfile(),
409    }
410    .map_err(|e| {
411        AwsServiceError::aws_error(
412            StatusCode::INTERNAL_SERVER_ERROR,
413            "InternalError",
414            format!("failed to create spool tempfile: {e}"),
415        )
416    })?;
417
418    // `into_temp_path` would auto-delete on drop. We keep the path and
419    // assume responsibility for either persisting or unlinking it.
420    let (std_file, temp_path) = named.into_parts();
421    // Persist to a stable PathBuf — `keep()` releases the
422    // delete-on-drop guard so the file outlives this function.
423    let path: PathBuf = temp_path.keep().map_err(|e| {
424        AwsServiceError::aws_error(
425            StatusCode::INTERNAL_SERVER_ERROR,
426            "InternalError",
427            format!("failed to persist spool tempfile: {e}"),
428        )
429    })?;
430
431    let mut file = tokio::fs::File::from_std(std_file);
432    let mut hasher = Md5::new();
433    let mut size: u64 = 0;
434    let mut body = stream;
435    let mut decoder = aws_chunked.then(AwsChunkedDecoder::default);
436    let mut http_trailers: Vec<(String, String)> = Vec::new();
437
438    // Cleanup helper: drop the file handle before unlinking so
439    // platforms that disallow removing an open file (Windows) still
440    // collect the partial spool. `drop(file)` closes the underlying
441    // OS handle synchronously.
442    async fn cleanup(file: tokio::fs::File, path: &std::path::Path) {
443        drop(file);
444        let _ = tokio::fs::remove_file(path).await;
445    }
446
447    loop {
448        match body.frame().await {
449            Some(Ok(frame)) => {
450                let frame = match frame.into_trailers() {
451                    Ok(trailers) => {
452                        for (name, value) in trailers.iter() {
453                            if let Ok(v) = value.to_str() {
454                                http_trailers
455                                    .push((name.as_str().to_string(), v.trim().to_string()));
456                            }
457                        }
458                        continue;
459                    }
460                    Err(frame) => frame,
461                };
462                if let Ok(raw) = frame.into_data() {
463                    if !raw.is_empty() {
464                        // Decode aws-chunked framing into the real payload when
465                        // the client used it; otherwise the frame IS the payload.
466                        let payload = match decoder.as_mut() {
467                            Some(d) => match d.feed(&raw) {
468                                Ok(decoded) => decoded,
469                                Err(_) => {
470                                    cleanup(file, &path).await;
471                                    return Err(AwsServiceError::aws_error(
472                                        StatusCode::BAD_REQUEST,
473                                        "InvalidChunkSizeError",
474                                        "Malformed aws-chunked request body",
475                                    ));
476                                }
477                            },
478                            None => raw.to_vec(),
479                        };
480                        if !payload.is_empty() {
481                            hasher.update(&payload);
482                            observer(&payload);
483                            size += payload.len() as u64;
484                            if let Err(e) = file.write_all(&payload).await {
485                                cleanup(file, &path).await;
486                                return Err(AwsServiceError::aws_error(
487                                    StatusCode::INTERNAL_SERVER_ERROR,
488                                    "InternalError",
489                                    format!("failed to spool request body: {e}"),
490                                ));
491                            }
492                        }
493                    }
494                }
495            }
496            Some(Err(e)) => {
497                cleanup(file, &path).await;
498                return Err(stream_error_to_aws(&e.to_string()));
499            }
500            None => break,
501        }
502    }
503
504    if let Err(e) = file.flush().await {
505        cleanup(file, &path).await;
506        return Err(AwsServiceError::aws_error(
507            StatusCode::INTERNAL_SERVER_ERROR,
508            "InternalError",
509            format!("failed to flush spool tempfile: {e}"),
510        ));
511    }
512    drop(file);
513
514    let md5: [u8; 16] = hasher.finalize().into();
515    let md5_hex = hex_lower(&md5);
516    let mut trailers = decoder
517        .map(AwsChunkedDecoder::into_trailers)
518        .unwrap_or_default();
519    trailers.extend(http_trailers);
520    Ok(SpooledBody {
521        path,
522        size,
523        md5_hex,
524        md5,
525        trailers,
526    })
527}
528
529fn hex_lower(bytes: &[u8]) -> String {
530    const HEX: &[u8] = b"0123456789abcdef";
531    let mut out = String::with_capacity(bytes.len() * 2);
532    for b in bytes {
533        out.push(HEX[(b >> 4) as usize] as char);
534        out.push(HEX[(b & 0x0f) as usize] as char);
535    }
536    out
537}
538
539/// A response body. Most handlers return [`ResponseBody::Bytes`] built from
540/// an in-memory [`Bytes`] buffer; the [`File`](ResponseBody::File) variant
541/// exists so large disk-backed objects can be streamed straight from the
542/// filesystem to the HTTP body without being materialized into RAM. The file
543/// handle is opened by the service handler while it still holds the
544/// per-bucket read guard, so the reader sees a consistent inode even if a
545/// concurrent PUT/DELETE renames or unlinks the path before dispatch streams
546/// the body.
547#[derive(Debug)]
548pub enum ResponseBody {
549    Bytes(Bytes),
550    File { file: tokio::fs::File, size: u64 },
551}
552
553impl ResponseBody {
554    pub fn len(&self) -> u64 {
555        match self {
556            ResponseBody::Bytes(b) => b.len() as u64,
557            ResponseBody::File { size, .. } => *size,
558        }
559    }
560
561    pub fn is_empty(&self) -> bool {
562        self.len() == 0
563    }
564
565    /// Accessor that returns the bytes of a `Bytes` variant and panics for
566    /// `File`. Used by tests and by callers that know the response was built
567    /// from an in-memory buffer (JSON handlers, cross-service glue).
568    pub fn expect_bytes(&self) -> &[u8] {
569        match self {
570            ResponseBody::Bytes(b) => b,
571            ResponseBody::File { .. } => {
572                panic!("expect_bytes called on ResponseBody::File")
573            }
574        }
575    }
576}
577
578impl Default for ResponseBody {
579    fn default() -> Self {
580        ResponseBody::Bytes(Bytes::new())
581    }
582}
583
584impl From<Bytes> for ResponseBody {
585    fn from(b: Bytes) -> Self {
586        ResponseBody::Bytes(b)
587    }
588}
589
590impl From<Vec<u8>> for ResponseBody {
591    fn from(v: Vec<u8>) -> Self {
592        ResponseBody::Bytes(Bytes::from(v))
593    }
594}
595
596impl From<&'static [u8]> for ResponseBody {
597    fn from(s: &'static [u8]) -> Self {
598        ResponseBody::Bytes(Bytes::from_static(s))
599    }
600}
601
602impl From<String> for ResponseBody {
603    fn from(s: String) -> Self {
604        ResponseBody::Bytes(Bytes::from(s))
605    }
606}
607
608impl From<&'static str> for ResponseBody {
609    fn from(s: &'static str) -> Self {
610        ResponseBody::Bytes(Bytes::from_static(s.as_bytes()))
611    }
612}
613
614impl PartialEq<Bytes> for ResponseBody {
615    fn eq(&self, other: &Bytes) -> bool {
616        match self {
617            ResponseBody::Bytes(b) => b == other,
618            ResponseBody::File { .. } => false,
619        }
620    }
621}
622
623/// A response from a service handler.
624pub struct AwsResponse {
625    pub status: StatusCode,
626    pub content_type: String,
627    pub body: ResponseBody,
628    pub headers: HeaderMap,
629}
630
631impl AwsResponse {
632    pub fn xml(status: StatusCode, body: impl Into<Bytes>) -> Self {
633        Self {
634            status,
635            content_type: "text/xml".to_string(),
636            body: ResponseBody::Bytes(body.into()),
637            headers: HeaderMap::new(),
638        }
639    }
640
641    pub fn json(status: StatusCode, body: impl Into<Bytes>) -> Self {
642        Self {
643            status,
644            content_type: "application/x-amz-json-1.1".to_string(),
645            body: ResponseBody::Bytes(body.into()),
646            headers: HeaderMap::new(),
647        }
648    }
649
650    /// Build a JSON response from a `serde_json::Value` with an explicit status.
651    ///
652    /// Serialization of an in-memory `Value` cannot fail — it has no cycles and
653    /// no custom serializers — so the inner `to_vec` is documented as infallible
654    /// rather than left as a bare `unwrap()`.
655    pub fn json_value(status: StatusCode, value: serde_json::Value) -> Self {
656        Self::json(
657            status,
658            serde_json::to_vec(&value).expect("serde_json::Value serialization is infallible"),
659        )
660    }
661
662    /// Convenience constructor for a 200 OK JSON response from a `serde_json::Value`.
663    pub fn ok_json(value: serde_json::Value) -> Self {
664        Self::json_value(StatusCode::OK, value)
665    }
666}
667
668/// Error returned by service handlers.
669#[derive(Debug, thiserror::Error)]
670pub enum AwsServiceError {
671    #[error("service not found: {service}")]
672    ServiceNotFound { service: String },
673
674    #[error("action {action} not implemented for service {service}")]
675    ActionNotImplemented { service: String, action: String },
676
677    #[error("{code}: {message}")]
678    AwsError {
679        status: StatusCode,
680        code: String,
681        message: String,
682        /// Additional key-value pairs to include in the error XML (e.g., BucketName, Key, Condition).
683        extra_fields: Vec<(String, String)>,
684        /// Additional HTTP headers to include in the error response.
685        headers: Vec<(String, String)>,
686    },
687}
688
689impl AwsServiceError {
690    pub fn action_not_implemented(service: &str, action: &str) -> Self {
691        Self::ActionNotImplemented {
692            service: service.to_string(),
693            action: action.to_string(),
694        }
695    }
696
697    pub fn aws_error(
698        status: StatusCode,
699        code: impl Into<String>,
700        message: impl Into<String>,
701    ) -> Self {
702        Self::AwsError {
703            status,
704            code: code.into(),
705            message: message.into(),
706            extra_fields: Vec::new(),
707            headers: Vec::new(),
708        }
709    }
710
711    pub fn aws_error_with_fields(
712        status: StatusCode,
713        code: impl Into<String>,
714        message: impl Into<String>,
715        extra_fields: Vec<(String, String)>,
716    ) -> Self {
717        Self::AwsError {
718            status,
719            code: code.into(),
720            message: message.into(),
721            extra_fields,
722            headers: Vec::new(),
723        }
724    }
725
726    pub fn aws_error_with_headers(
727        status: StatusCode,
728        code: impl Into<String>,
729        message: impl Into<String>,
730        headers: Vec<(String, String)>,
731    ) -> Self {
732        Self::AwsError {
733            status,
734            code: code.into(),
735            message: message.into(),
736            extra_fields: Vec::new(),
737            headers,
738        }
739    }
740
741    pub fn extra_fields(&self) -> &[(String, String)] {
742        match self {
743            Self::AwsError { extra_fields, .. } => extra_fields,
744            _ => &[],
745        }
746    }
747
748    pub fn status(&self) -> StatusCode {
749        match self {
750            Self::ServiceNotFound { .. } => StatusCode::BAD_REQUEST,
751            Self::ActionNotImplemented { .. } => StatusCode::NOT_IMPLEMENTED,
752            Self::AwsError { status, .. } => *status,
753        }
754    }
755
756    pub fn code(&self) -> &str {
757        match self {
758            Self::ServiceNotFound { .. } => "UnknownService",
759            Self::ActionNotImplemented { .. } => "InvalidAction",
760            Self::AwsError { code, .. } => code,
761        }
762    }
763
764    pub fn message(&self) -> String {
765        match self {
766            Self::ServiceNotFound { service } => format!("service not found: {service}"),
767            Self::ActionNotImplemented { service, action } => {
768                format!("action {action} not implemented for service {service}")
769            }
770            Self::AwsError { message, .. } => message.clone(),
771        }
772    }
773
774    pub fn response_headers(&self) -> &[(String, String)] {
775        match self {
776            Self::AwsError { headers, .. } => headers,
777            _ => &[],
778        }
779    }
780}
781
782/// Why a data plane claims a request no protocol signal routed (see
783/// [`AwsService::claim_unrouted_request`]).
784#[derive(Debug, Clone, Copy, PartialEq, Eq)]
785pub enum DataPlaneClaim {
786    /// The `Host` names the data plane (an execute-api host, a custom domain).
787    /// Nothing sent to such a host is ever an S3 request.
788    Host,
789    /// Only the path suggests it (an invocation URL, a stage name as the
790    /// first segment) on a host S3 also serves.
791    Path,
792}
793
794/// What dispatch knows of a request before reading its body: enough for a
795/// service to classify it ([`AwsService::streams_request_body`]).
796pub struct RequestHead<'a> {
797    pub method: &'a Method,
798    /// The raw (undecoded) path the request is routed on; for S3 the
799    /// virtual-hosted bucket is already prefixed, as in
800    /// [`AwsRequest::raw_path`].
801    pub raw_path: &'a str,
802    /// The request's headers, a presigned URL's `x-amz-*` query parameters
803    /// among them, as in [`AwsRequest::headers`].
804    pub headers: &'a HeaderMap,
805    pub query_params: &'a HashMap<String, String>,
806    /// Whether the request presents no credentials at all (no signature,
807    /// no presigned credential, no in-process caller).
808    pub anonymous: bool,
809    pub region: &'a str,
810    pub request_id: &'a str,
811}
812
813/// Trait that every AWS service implements.
814#[async_trait]
815pub trait AwsService: Send + Sync {
816    /// The AWS service identifier (e.g., "sqs", "sns", "sts", "events", "ssm").
817    fn service_name(&self) -> &str;
818
819    /// Handle an incoming request.
820    async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError>;
821
822    /// List of actions this service supports (for introspection).
823    fn supported_actions(&self) -> &[&str];
824
825    /// Whether this service's data plane owns a request that no protocol
826    /// signal -- target header, `Action`, signing scope or AWS endpoint host --
827    /// routed, and on what grounds. `raw_path` is the undecoded wire path and
828    /// `account_id` the account an anonymous request runs in.
829    ///
830    /// On the shared listener such a request is either an S3 path-style
831    /// request (`/{bucket}/{key}` on S3's endpoint) or an invocation of a
832    /// deployed API. A [`DataPlaneClaim::Host`] claim always wins; a
833    /// [`DataPlaneClaim::Path`] claim loses to an existing bucket of the same
834    /// name and to S3 query authentication. Dispatch sends unclaimed unsigned
835    /// requests to S3. Defaults to `None`.
836    fn claim_unrouted_request(
837        &self,
838        _method: &Method,
839        _raw_path: &str,
840        _headers: &HeaderMap,
841        _account_id: &str,
842    ) -> Option<DataPlaneClaim> {
843        None
844    }
845
846    /// Whether this service participates in opt-in IAM enforcement
847    /// (`FAKECLOUD_IAM=soft|strict`).
848    ///
849    /// Defaults to `false`: unless a service has a full
850    /// `iam_action_for` implementation covering every operation it
851    /// supports plus resource-ARN extractors, it's silently skipped when
852    /// IAM enforcement is on. The startup log enumerates which services
853    /// are enforced and which are not so users always know the current
854    /// enforcement surface.
855    ///
856    /// Phase 1 contract: a service that returns `true` here MUST also
857    /// provide a fully populated [`AwsService::iam_action_for`]
858    /// implementation covering every action it advertises. Returning
859    /// `true` without the action mapping is a programming bug.
860    fn iam_enforceable(&self) -> bool {
861        false
862    }
863
864    /// Whether `request` needs authorization at all. A few requests need no
865    /// permission and no policy can deny them; dispatch then evaluates no
866    /// policy for the request. The service decides for every caller:
867    /// `sts:GetCallerIdentity` needs none from a caller whose credentials
868    /// resolved but still refuses an anonymous one (so it answers `true`
869    /// for that), while an S3 CORS preflight is never authorized, whoever
870    /// sends it. Defaults to `true`.
871    fn iam_authorization_required(&self, _request: &AwsRequest) -> bool {
872        true
873    }
874
875    /// Derive the IAM action + resource ARN for an incoming request.
876    ///
877    /// Only called when [`AwsService::iam_enforceable`] returns `true`
878    /// and IAM enforcement is enabled. Services must map every action
879    /// they implement; returning `None` for a covered action causes the
880    /// evaluator to skip the request and flag it via the
881    /// `fakecloud::iam::audit` tracing target so gaps are visible in
882    /// soft mode.
883    ///
884    /// The `IamAction.resource` is built from `request.principal`'s
885    /// account id (not global config) so multi-account isolation
886    /// (#381) works once per-account state partitioning lands.
887    fn iam_action_for(&self, _request: &AwsRequest) -> Option<crate::auth::IamAction> {
888        None
889    }
890
891    /// Every IAM authorization an incoming request needs.
892    ///
893    /// Most operations act on one resource and need one action, which is
894    /// what the default returns ([`AwsService::iam_action_for`]). Some need
895    /// several: a batch or transaction naming several resources needs the
896    /// action on each, and an operation can require more than one action
897    /// (DynamoDB's `CreateTable` with `Tags` also needs `TagResource`).
898    /// Dispatch evaluates every action returned and denies the request if
899    /// any of them is denied. An empty list means the operation has no
900    /// mapping, which strict enforcement denies.
901    fn iam_actions_for(&self, request: &AwsRequest) -> Vec<crate::auth::IamAction> {
902        self.iam_action_for(request).into_iter().collect()
903    }
904
905    /// Whether the handler reads this request's body as a stream
906    /// ([`AwsRequest::take_body_stream`]) instead of from a buffered
907    /// [`AwsRequest::body`]. Dispatch asks only for a request its headers
908    /// already route to this service, and buffers the body otherwise, so
909    /// everything that runs before the handler (IAM enforcement reading the
910    /// request's tags, for one) sees it. The service decides by the same
911    /// classification its handler routes on. Default `false`.
912    fn streams_request_body(&self, _head: &RequestHead<'_>) -> bool {
913        false
914    }
915
916    /// Refuse a request from its head alone, before dispatch reads its body:
917    /// what [`AwsService::validate_before_authorization`] refuses without
918    /// needing the body (S3: conflicting resource-type parameters, a method
919    /// a resource type does not take, a missing upload, a missing bucket for
920    /// an anonymous caller), so a request refused for its URL is never
921    /// buffered (up to the request-size cap) first. Dispatch calls it for a
922    /// request its headers alone settle as this service's
923    /// ([`crate::protocol::headers_name_final_service`]), IAM enforcement on
924    /// or off, and answers `Err` as the service's error. The later
925    /// validation must refuse the same requests the same way.
926    ///
927    /// It runs before SigV4 verification and before authentication: the
928    /// caller's identity is not resolved yet, and [`RequestHead::anonymous`]
929    /// says only whether a credential was presented. That is the order S3
930    /// answers in (recorded): a request signed with an unknown key and
931    /// conflicting resource-type parameters, or a `PUT` with `partNumber`
932    /// and no `uploadId`, is 400 for its URL, not `InvalidClientTokenId`.
933    /// A service whose URL refusals come after authentication must not
934    /// refuse here. Default: nothing to refuse.
935    fn refuse_before_body(&self, _head: &RequestHead<'_>) -> Result<(), AwsServiceError> {
936        Ok(())
937    }
938
939    /// Put a buffered request into the form every later consumer reads --
940    /// pre-authorization validation, IAM derivation and the handler alike --
941    /// so none of them works on a different body than the others (S3
942    /// removes the `aws-chunked` framing of a small XML body here). Dispatch
943    /// calls it first, IAM enforcement on or off, and answers `Err` as the
944    /// service's error. It must be idempotent: a handler called directly
945    /// may run it again. Default: nothing to do.
946    fn prepare_request(&self, _request: &mut AwsRequest) -> Result<(), AwsServiceError> {
947        Ok(())
948    }
949
950    /// Whether an anonymous (unsigned) request is validated
951    /// ([`AwsService::validate_before_authorization`]) before it is
952    /// authorized, rather than refused as anonymous first. Only a service
953    /// that serves anonymous requests itself and judges them like any other
954    /// opts in (S3: a missing bucket is NoSuchBucket, an unclassifiable
955    /// request its 400, whoever asks). Every other service refuses an
956    /// unsigned request before looking at it (recorded: an unsigned STS
957    /// `AssumeRoot` with an invalid `TaskPolicyArn` is refused for its
958    /// missing credentials, not validated). Default `false`.
959    fn validates_anonymous_before_authorization(&self) -> bool {
960        false
961    }
962
963    /// Whether a REST request addressed to this service's API host is one of
964    /// its modeled control-plane operations, rather than a data-plane request
965    /// the service also serves there (an API Gateway invocation). Only a
966    /// control-plane request is refused for missing credentials
967    /// ([`AwsService::anonymous_access`]). Defaults to `true`.
968    fn is_control_plane_request(&self, _request: &AwsRequest) -> bool {
969        true
970    }
971
972    /// What this service does with an unsigned request to the operation
973    /// `request` names, under `--iam soft|strict` (with enforcement off every
974    /// unsigned request is served). Dispatch asks for a request that carries
975    /// no credential at all and names an API operation: an awsJson target, a
976    /// Query `Action`, or a REST request addressed to this service's API
977    /// host. [`AnonymousAccess::Refused`] answers it with the service's
978    /// missing-credentials error before validation, authorization or the
979    /// handler (strict; soft logs it); [`AnonymousAccess::Authorized`]
980    /// authorizes the anonymous principal against the resource's policy;
981    /// [`AnonymousAccess::Unauthenticated`] serves it without authorization.
982    /// Defaults to the Smithy model's verdict
983    /// ([`AnonymousAccess::for_operation`]): unauthenticated for an operation
984    /// marked `optionalAuth` or `auth: []`, refused otherwise.
985    ///
986    /// [`AnonymousAccess::Refused`]: crate::anonymous::AnonymousAccess::Refused
987    /// [`AnonymousAccess::Authorized`]: crate::anonymous::AnonymousAccess::Authorized
988    /// [`AnonymousAccess::Unauthenticated`]: crate::anonymous::AnonymousAccess::Unauthenticated
989    /// [`AnonymousAccess::for_operation`]: crate::anonymous::AnonymousAccess::for_operation
990    fn anonymous_access(&self, request: &AwsRequest) -> crate::anonymous::AnonymousAccess {
991        crate::anonymous::AnonymousAccess::for_operation(&request.service, &request.action)
992    }
993
994    /// Validation AWS performs before authorizing `request`: an error here
995    /// is returned to the caller whatever its permissions, anonymous callers
996    /// included where the service validates them first
997    /// ([`AwsService::validates_anonymous_before_authorization`]) -- a
998    /// request the service cannot classify into an operation
999    /// (S3 answers conflicting sub-resource parameters with 400
1000    /// `InvalidArgument` whoever asks), or one carrying an invalid parameter
1001    /// AWS checks first (STS `AssumeRoot`'s `TaskPolicyArn`). Dispatch calls
1002    /// it for every request, once: after authentication (a key that resolves
1003    /// to no identity is refused before it) and before authorization under
1004    /// IAM enforcement, just before the handler otherwise. `Err` is answered
1005    /// as the service's error, so such a request gets it rather than an
1006    /// `AccessDenied` and is never authorized as some other operation.
1007    /// Default: nothing to refuse.
1008    fn validate_before_authorization(&self, _request: &AwsRequest) -> Result<(), AwsServiceError> {
1009        Ok(())
1010    }
1011
1012    /// Whether the handler authorizes this request itself, item by item,
1013    /// through [`crate::auth::request_authorizer`] -- an operation AWS
1014    /// authorizes per item and answers with per-item errors (S3
1015    /// `DeleteObjects` reports a denied key as an `AccessDenied` entry of a
1016    /// 200 response). Dispatch then evaluates no action for the request
1017    /// (the caller's credentials are still checked). Default `false`.
1018    fn iam_authorized_by_handler(&self, _request: &AwsRequest) -> bool {
1019        false
1020    }
1021
1022    /// Whether the request creates its resource in the CALLER's account, so
1023    /// that whatever currently holds the resource's name elsewhere has no say
1024    /// in authorizing it. S3's `CreateBucket` is the case: bucket names are
1025    /// global, and creating a name another account owns is authorized against
1026    /// the caller's own policies (then answered `BucketAlreadyExists`), never
1027    /// against the other account's bucket policy. Dispatch then evaluates the
1028    /// request as same-account with no resource policy. Default `false`.
1029    fn iam_resource_in_caller_account(&self, _request: &AwsRequest) -> bool {
1030        false
1031    }
1032
1033    /// Derive service-specific IAM condition keys for an incoming request.
1034    ///
1035    /// Called right after [`AwsService::iam_action_for`] when IAM
1036    /// enforcement is enabled. The returned map is merged into the
1037    /// [`crate::auth::ConditionContext::service_keys`] before the
1038    /// evaluator runs, so policies can reference keys like `s3:prefix`
1039    /// or `sns:Protocol` the same way they reference global keys.
1040    ///
1041    /// Keys MUST be in the full `"service:key"` form, lowercased
1042    /// (e.g. `"s3:prefix"`), matching the case-insensitive lookup in
1043    /// [`crate::auth::ConditionContext::lookup`]. Extractors should
1044    /// only emit keys they can populate with confidence; anything
1045    /// ambiguous or unimplemented should be skipped with a
1046    /// `tracing::debug!(target: "fakecloud::iam::audit", ...)` so
1047    /// condition evaluation safe-fails to "doesn't apply" rather than
1048    /// "matches".
1049    ///
1050    /// Default impl returns an empty map: services that haven't been
1051    /// plumbed yet behave exactly as before.
1052    fn iam_condition_keys_for(
1053        &self,
1054        _request: &AwsRequest,
1055        _action: &crate::auth::IamAction,
1056    ) -> BTreeMap<String, Vec<String>> {
1057        BTreeMap::new()
1058    }
1059
1060    /// Return the tags on the resource identified by `resource_arn`.
1061    ///
1062    /// Called at dispatch time when IAM enforcement is enabled, right
1063    /// after [`AwsService::iam_action_for`]. The returned map populates
1064    /// `aws:ResourceTag/<key>` condition keys so policies can gate
1065    /// access based on the target resource's tags.
1066    ///
1067    /// Return `None` to signal that this service does not (yet) support
1068    /// resource-tag ABAC — dispatch will emit a debug audit log and
1069    /// skip `aws:ResourceTag/*` evaluation. Return `Some(empty map)`
1070    /// when the resource exists but has no tags.
1071    fn resource_tags_for(
1072        &self,
1073        _resource_arn: &str,
1074    ) -> Option<std::collections::HashMap<String, String>> {
1075        None
1076    }
1077
1078    /// Extract tags being sent in the request (e.g. on CreateQueue,
1079    /// PutObject with `x-amz-tagging`, TagResource).
1080    ///
1081    /// The returned map populates `aws:RequestTag/<key>` and
1082    /// `aws:TagKeys` condition keys. Return `None` when the service
1083    /// does not (yet) support request-tag extraction — dispatch skips
1084    /// `aws:RequestTag/*` / `aws:TagKeys` evaluation with a debug log.
1085    /// Return `Some(empty map)` when the request legitimately carries
1086    /// no tags.
1087    fn request_tags_from(
1088        &self,
1089        _request: &AwsRequest,
1090        _action: &str,
1091    ) -> Option<std::collections::HashMap<String, String>> {
1092        None
1093    }
1094}
1095
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use crate::auth::IamAction;
1100    use async_trait::async_trait;
1101
1102    /// Build a signed aws-chunked body for `payload`, split into chunks of
1103    /// `chunk_size`, terminated by a 0-chunk + a trailer + final CRLF.
1104    fn aws_chunked_body(payload: &[u8], chunk_size: usize, with_trailer: bool) -> Vec<u8> {
1105        let sig = "0".repeat(64);
1106        let mut out = Vec::new();
1107        for c in payload.chunks(chunk_size.max(1)) {
1108            out.extend_from_slice(format!("{:x};chunk-signature={sig}\r\n", c.len()).as_bytes());
1109            out.extend_from_slice(c);
1110            out.extend_from_slice(b"\r\n");
1111        }
1112        out.extend_from_slice(format!("0;chunk-signature={sig}\r\n").as_bytes());
1113        if with_trailer {
1114            out.extend_from_slice(b"x-amz-checksum-crc32:AAAAAA==\r\n");
1115        }
1116        out.extend_from_slice(b"\r\n");
1117        out
1118    }
1119
1120    fn decode_all(body: &[u8], feed_size: usize) -> Vec<u8> {
1121        let mut d = AwsChunkedDecoder::default();
1122        let mut out = Vec::new();
1123        for frame in body.chunks(feed_size.max(1)) {
1124            out.extend(d.feed(frame).expect("valid chunked body"));
1125        }
1126        out
1127    }
1128
1129    #[test]
1130    fn aws_chunked_decoder_roundtrips_across_frame_boundaries() {
1131        let payload: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
1132        // Chunked with 1 KiB data chunks; with and without a trailer.
1133        for with_trailer in [false, true] {
1134            let body = aws_chunked_body(&payload, 1024, with_trailer);
1135            // Network frames don't align to chunk boundaries: try several sizes,
1136            // including 1 byte at a time and a single whole-body frame.
1137            for feed in [1usize, 7, 64, 1000, body.len()] {
1138                let decoded = decode_all(&body, feed);
1139                assert_eq!(decoded, payload, "feed={feed} trailer={with_trailer}");
1140            }
1141        }
1142    }
1143
1144    #[test]
1145    fn aws_chunked_decoder_collects_trailers_across_frame_boundaries() {
1146        let payload: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
1147        let body = aws_chunked_body(&payload, 1024, true);
1148        for feed in [1usize, 5, 333, body.len()] {
1149            let mut d = AwsChunkedDecoder::default();
1150            for frame in body.chunks(feed) {
1151                d.feed(frame).unwrap();
1152            }
1153            assert_eq!(
1154                d.trailers(),
1155                &[("x-amz-checksum-crc32".to_string(), "AAAAAA==".to_string())],
1156                "feed={feed}"
1157            );
1158        }
1159    }
1160
1161    #[test]
1162    fn aws_chunked_trailer_signature_and_optional_newline_are_handled() {
1163        // Signed trailer form: the checksum line (with the optional bare `\n`
1164        // some clients emit), then the trailer signature, then the final CRLF.
1165        let body = b"5\r\nhello\r\n0\r\nX-Amz-Checksum-Sha256: abc= \n\r\n";
1166        let decoded = decode_aws_chunked(body).unwrap();
1167        assert_eq!(decoded.payload, b"hello");
1168        assert_eq!(
1169            decoded.trailers,
1170            vec![("x-amz-checksum-sha256".to_string(), "abc=".to_string())]
1171        );
1172        let signed =
1173            b"3\r\nabc\r\n0\r\nx-amz-checksum-crc32c:Zm9v\r\nx-amz-trailer-signature:dead\r\n\r\n";
1174        let decoded = decode_aws_chunked(signed).unwrap();
1175        assert_eq!(decoded.payload, b"abc");
1176        assert_eq!(
1177            decoded.trailers,
1178            vec![("x-amz-checksum-crc32c".to_string(), "Zm9v".to_string())]
1179        );
1180        assert!(decode_aws_chunked(b"zz\r\nabc").is_err());
1181    }
1182
1183    #[tokio::test]
1184    async fn spool_observer_sees_every_decoded_byte_and_trailers_are_returned() {
1185        let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
1186        let body = aws_chunked_body(&payload, 1024, true);
1187        let mut seen = Vec::new();
1188        let spooled =
1189            spool_request_stream_observed(axum::body::Body::from(body), None, true, &mut |chunk| {
1190                seen.extend_from_slice(chunk)
1191            })
1192            .await
1193            .expect("spool ok");
1194        assert_eq!(seen, payload);
1195        assert_eq!(
1196            spooled.trailers,
1197            vec![("x-amz-checksum-crc32".to_string(), "AAAAAA==".to_string())]
1198        );
1199        let _ = std::fs::remove_file(&spooled.path);
1200    }
1201
1202    #[test]
1203    fn aws_chunked_decoder_handles_empty_payload() {
1204        let body = aws_chunked_body(b"", 1024, false);
1205        assert_eq!(decode_all(&body, 3), Vec::<u8>::new());
1206    }
1207
1208    #[tokio::test]
1209    async fn spool_md5_is_over_decoded_aws_chunked_payload() {
1210        // The ETag must describe the DECODED bytes, never the chunk framing.
1211        let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
1212        let body = aws_chunked_body(&payload, 1024, true);
1213        let spooled = spool_request_stream(axum::body::Body::from(body), None, true)
1214            .await
1215            .expect("spool ok");
1216        assert_eq!(spooled.size, payload.len() as u64);
1217        let expected: [u8; 16] = Md5::digest(&payload).into();
1218        assert_eq!(spooled.md5, expected);
1219        assert_eq!(spooled.md5_hex, hex_lower(&expected));
1220        assert_eq!(std::fs::read(&spooled.path).unwrap(), payload);
1221        let _ = std::fs::remove_file(&spooled.path);
1222    }
1223
1224    #[test]
1225    fn aws_chunked_decoder_rejects_bad_size_line() {
1226        let mut d = AwsChunkedDecoder::default();
1227        assert!(d.feed(b"zz;chunk-signature=x\r\n").is_err());
1228    }
1229
1230    #[test]
1231    fn is_aws_chunked_detects_streaming_markers() {
1232        let mut h = http::HeaderMap::new();
1233        assert!(!is_aws_chunked(&h));
1234        h.insert("content-encoding", "aws-chunked".parse().unwrap());
1235        assert!(is_aws_chunked(&h));
1236        let mut h2 = http::HeaderMap::new();
1237        h2.insert(
1238            "x-amz-content-sha256",
1239            "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".parse().unwrap(),
1240        );
1241        assert!(is_aws_chunked(&h2));
1242        // gzip without aws-chunked must NOT trigger decoding.
1243        let mut h3 = http::HeaderMap::new();
1244        h3.insert("content-encoding", "gzip".parse().unwrap());
1245        assert!(!is_aws_chunked(&h3));
1246    }
1247
1248    #[test]
1249    fn strip_aws_chunked_keeps_real_encoding() {
1250        assert_eq!(strip_aws_chunked_encoding(Some("aws-chunked")), None);
1251        assert_eq!(
1252            strip_aws_chunked_encoding(Some("aws-chunked, gzip")).as_deref(),
1253            Some("gzip")
1254        );
1255        assert_eq!(
1256            strip_aws_chunked_encoding(Some("gzip")).as_deref(),
1257            Some("gzip")
1258        );
1259        assert_eq!(strip_aws_chunked_encoding(None), None);
1260    }
1261
1262    struct DefaultService;
1263
1264    #[async_trait]
1265    impl AwsService for DefaultService {
1266        fn service_name(&self) -> &str {
1267            "default"
1268        }
1269        async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1270            unreachable!()
1271        }
1272        fn supported_actions(&self) -> &[&str] {
1273            &[]
1274        }
1275    }
1276
1277    struct PopulatedService;
1278
1279    #[async_trait]
1280    impl AwsService for PopulatedService {
1281        fn service_name(&self) -> &str {
1282            "populated"
1283        }
1284        async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1285            unreachable!()
1286        }
1287        fn supported_actions(&self) -> &[&str] {
1288            &[]
1289        }
1290        fn iam_condition_keys_for(
1291            &self,
1292            _request: &AwsRequest,
1293            _action: &IamAction,
1294        ) -> BTreeMap<String, Vec<String>> {
1295            let mut m = BTreeMap::new();
1296            m.insert("s3:prefix".to_string(), vec!["logs/".to_string()]);
1297            m
1298        }
1299    }
1300
1301    fn sample_request() -> AwsRequest {
1302        AwsRequest {
1303            service: "default".into(),
1304            action: "Noop".into(),
1305            region: "us-east-1".into(),
1306            account_id: "123456789012".into(),
1307            request_id: "req-1".into(),
1308            headers: HeaderMap::new(),
1309            query_params: HashMap::new(),
1310            body: Bytes::new(),
1311            body_stream: parking_lot::Mutex::new(None),
1312            path_segments: vec![],
1313            raw_path: "/".into(),
1314            raw_query: String::new(),
1315            method: Method::GET,
1316            is_query_protocol: false,
1317            access_key_id: None,
1318            principal: None,
1319        }
1320    }
1321
1322    fn sample_action() -> IamAction {
1323        IamAction {
1324            service: "s3",
1325            action: "ListBucket",
1326            resource: "arn:aws:s3:::my-bucket".to_string(),
1327        }
1328    }
1329
1330    #[test]
1331    fn iam_condition_keys_for_default_is_empty() {
1332        let svc = DefaultService;
1333        let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1334        assert!(keys.is_empty());
1335    }
1336
1337    #[test]
1338    fn iam_condition_keys_for_override_returns_map() {
1339        let svc = PopulatedService;
1340        let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1341        assert_eq!(keys.get("s3:prefix"), Some(&vec!["logs/".to_string()]));
1342    }
1343
1344    #[test]
1345    fn response_body_len_and_is_empty_for_bytes() {
1346        let body: ResponseBody = Bytes::from_static(b"hello").into();
1347        assert_eq!(body.len(), 5);
1348        assert!(!body.is_empty());
1349        let empty: ResponseBody = ResponseBody::default();
1350        assert!(empty.is_empty());
1351    }
1352
1353    #[test]
1354    fn response_body_from_vec_and_string_and_str() {
1355        let from_vec: ResponseBody = vec![1u8, 2, 3].into();
1356        assert_eq!(from_vec.expect_bytes(), &[1, 2, 3][..]);
1357        let from_string: ResponseBody = String::from("hi").into();
1358        assert_eq!(from_string.expect_bytes(), b"hi");
1359        let from_str: ResponseBody = "hey".into();
1360        assert_eq!(from_str.expect_bytes(), b"hey");
1361        let from_static: ResponseBody = (b"123" as &'static [u8]).into();
1362        assert_eq!(from_static.expect_bytes(), b"123");
1363    }
1364
1365    #[test]
1366    fn response_body_partial_eq_bytes() {
1367        let body: ResponseBody = Bytes::from_static(b"x").into();
1368        assert!(body == Bytes::from_static(b"x"));
1369        assert!(!(body == Bytes::from_static(b"y")));
1370    }
1371
1372    #[test]
1373    fn aws_request_json_body_empty_returns_null() {
1374        let req = sample_request();
1375        assert_eq!(req.json_body(), serde_json::Value::Null);
1376    }
1377
1378    #[test]
1379    fn aws_request_json_body_parses_valid() {
1380        let mut req = sample_request();
1381        req.body = Bytes::from_static(br#"{"a":1}"#);
1382        assert_eq!(req.json_body(), serde_json::json!({"a": 1}));
1383    }
1384
1385    #[test]
1386    fn aws_response_xml_constructor() {
1387        let resp = AwsResponse::xml(StatusCode::OK, Bytes::from_static(b"<ok/>"));
1388        assert_eq!(resp.status, StatusCode::OK);
1389        assert_eq!(resp.content_type, "text/xml");
1390    }
1391
1392    #[test]
1393    fn aws_response_json_constructor() {
1394        let resp = AwsResponse::json(StatusCode::CREATED, "{}");
1395        assert_eq!(resp.status, StatusCode::CREATED);
1396        assert_eq!(resp.content_type, "application/x-amz-json-1.1");
1397    }
1398
1399    #[test]
1400    fn aws_response_ok_json_helper() {
1401        let resp = AwsResponse::ok_json(serde_json::json!({"ok": true}));
1402        assert_eq!(resp.status, StatusCode::OK);
1403        assert!(resp.body.expect_bytes().starts_with(b"{"));
1404    }
1405
1406    #[test]
1407    fn aws_error_service_not_found_fields() {
1408        let err = AwsServiceError::ServiceNotFound {
1409            service: "sqs".to_string(),
1410        };
1411        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1412        assert_eq!(err.code(), "UnknownService");
1413        assert!(err.message().contains("sqs"));
1414        assert!(err.extra_fields().is_empty());
1415        assert!(err.response_headers().is_empty());
1416    }
1417
1418    #[test]
1419    fn aws_error_action_not_implemented_fields() {
1420        let err = AwsServiceError::action_not_implemented("sns", "FutureAction");
1421        assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
1422        assert_eq!(err.code(), "InvalidAction");
1423        assert!(err.message().contains("FutureAction"));
1424        assert!(err.message().contains("sns"));
1425    }
1426
1427    #[test]
1428    fn aws_error_aws_error_helpers() {
1429        let e = AwsServiceError::aws_error(StatusCode::FORBIDDEN, "Denied", "no");
1430        assert_eq!(e.status(), StatusCode::FORBIDDEN);
1431        assert_eq!(e.code(), "Denied");
1432        assert_eq!(e.message(), "no");
1433
1434        let fields = vec![("Bucket".to_string(), "b".to_string())];
1435        let ef = AwsServiceError::aws_error_with_fields(
1436            StatusCode::NOT_FOUND,
1437            "Missing",
1438            "gone",
1439            fields.clone(),
1440        );
1441        assert_eq!(ef.extra_fields(), fields.as_slice());
1442
1443        let hdrs = vec![("X-Retry".to_string(), "1".to_string())];
1444        let eh = AwsServiceError::aws_error_with_headers(
1445            StatusCode::TOO_MANY_REQUESTS,
1446            "Throttled",
1447            "slow",
1448            hdrs.clone(),
1449        );
1450        assert_eq!(eh.response_headers(), hdrs.as_slice());
1451    }
1452
1453    #[test]
1454    #[should_panic(expected = "expect_bytes called on ResponseBody::File")]
1455    fn response_body_expect_bytes_panics_on_file() {
1456        let f = std::fs::File::create(std::env::temp_dir().join("fc-test-expect-file")).unwrap();
1457        let async_f = tokio::fs::File::from_std(f);
1458        let body = ResponseBody::File {
1459            file: async_f,
1460            size: 0,
1461        };
1462        let _ = body.expect_bytes();
1463    }
1464}