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    /// Lowercase-hex SHA-256 of the decoded payload, computed in the same
167    /// single streaming pass as the MD5. Lets the S3 layer verify a client's
168    /// `x-amz-content-sha256` header against the bytes actually received
169    /// (returning `XAmzContentSHA256Mismatch` on divergence) for plain,
170    /// non-`aws-chunked` uploads where the header carries the real payload
171    /// hash rather than a `STREAMING-…`/`UNSIGNED-PAYLOAD` marker.
172    pub sha256_hex: String,
173    /// The raw MD5 digest `md5_hex` spells out.
174    pub md5: [u8; 16],
175    /// The raw SHA-256 digest `sha256_hex` spells out.
176    pub sha256: [u8; 32],
177    /// Trailing headers that followed the payload: the `aws-chunked` trailer
178    /// section (e.g. `x-amz-checksum-crc32:<base64>`) and any HTTP/1.1 chunked
179    /// trailers. Names are lowercased, values trimmed; the trailer signature
180    /// line of a signed streaming upload is not included.
181    pub trailers: Vec<(String, String)>,
182}
183
184/// Incremental decoder for the `aws-chunked` content-encoding that modern AWS
185/// S3 clients (aws-cli, boto3 >= 1.36, aws-crt) apply by default to PutObject /
186/// UploadPart bodies when they send `x-amz-content-sha256:
187/// STREAMING-AWS4-HMAC-SHA256-PAYLOAD` (or `STREAMING-UNSIGNED-PAYLOAD-TRAILER`).
188///
189/// The wire format wraps the real payload in application-layer frames:
190/// `<hex-size>[;chunk-signature=<hex>]\r\n<data>\r\n` repeated, terminated by a
191/// `0`-size chunk, then optional `x-amz-trailer` lines, then a final `\r\n`.
192/// hyper only strips HTTP `Transfer-Encoding: chunked`, NOT this
193/// `Content-Encoding: aws-chunked` framing — so without decoding, the size /
194/// signature lines and trailers get stored as the object's bytes (silent
195/// corruption + a wrong ETag). This is fed incrementally because network frames
196/// do not align to chunk boundaries. Trailer lines (`x-amz-checksum-<algo>:<v>`)
197/// are collected so the service can verify the trailing checksum against the
198/// decoded bytes; see [`AwsChunkedDecoder::trailers`].
199#[derive(Default)]
200pub struct AwsChunkedDecoder {
201    state: ChunkState,
202    line: Vec<u8>,
203    remaining: usize,
204    done: bool,
205    trailers: Vec<(String, String)>,
206}
207
208#[derive(Default, PartialEq)]
209enum ChunkState {
210    #[default]
211    Header,
212    Data,
213    AfterData,
214    Trailer,
215}
216
217/// A malformed `aws-chunked` chunk-size line (non-hex length).
218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct MalformedChunk;
220
221impl AwsChunkedDecoder {
222    /// Feed a network frame; returns the decoded payload bytes it yielded.
223    /// Errors only on a malformed chunk-size line.
224    pub fn feed(&mut self, input: &[u8]) -> Result<Vec<u8>, MalformedChunk> {
225        let mut out = Vec::new();
226        let mut i = 0;
227        while i < input.len() && !self.done {
228            match self.state {
229                ChunkState::Data => {
230                    let take = self.remaining.min(input.len() - i);
231                    out.extend_from_slice(&input[i..i + take]);
232                    i += take;
233                    self.remaining -= take;
234                    if self.remaining == 0 {
235                        self.state = ChunkState::AfterData;
236                    }
237                }
238                ChunkState::AfterData => {
239                    // Consume the inter-chunk CRLF; next byte after \n is a header.
240                    while i < input.len() {
241                        let b = input[i];
242                        i += 1;
243                        if b == b'\n' {
244                            self.state = ChunkState::Header;
245                            break;
246                        }
247                    }
248                }
249                ChunkState::Header | ChunkState::Trailer => {
250                    let is_header = self.state == ChunkState::Header;
251                    while i < input.len() {
252                        let b = input[i];
253                        i += 1;
254                        if b == b'\n' {
255                            let line = std::mem::take(&mut self.line);
256                            if is_header {
257                                // size is the hex up to a `;` extension or EOL.
258                                let hex_part: &[u8] =
259                                    line.split(|&c| c == b';').next().unwrap_or(&[]);
260                                let hex = std::str::from_utf8(hex_part)
261                                    .map_err(|_| MalformedChunk)?
262                                    .trim();
263                                let size =
264                                    usize::from_str_radix(hex, 16).map_err(|_| MalformedChunk)?;
265                                if size == 0 {
266                                    self.state = ChunkState::Trailer;
267                                } else {
268                                    self.remaining = size;
269                                    self.state = ChunkState::Data;
270                                }
271                            } else if line.is_empty() {
272                                // Blank line ends the trailer section.
273                                self.done = true;
274                            } else {
275                                self.push_trailer(&line);
276                            }
277                            break;
278                        } else if b != b'\r' {
279                            self.line.push(b);
280                        }
281                    }
282                }
283            }
284        }
285        Ok(out)
286    }
287
288    /// Record one `name:value` trailer line. The signature of a signed
289    /// streaming upload (`x-amz-trailer-signature`) authenticates the trailer
290    /// rather than describing the payload, so it is not kept.
291    fn push_trailer(&mut self, line: &[u8]) {
292        let text = String::from_utf8_lossy(line);
293        let Some((name, value)) = text.split_once(':') else {
294            return;
295        };
296        let name = name.trim().to_ascii_lowercase();
297        if name.is_empty() || name == "x-amz-trailer-signature" {
298            return;
299        }
300        self.trailers.push((name, value.trim().to_string()));
301    }
302
303    /// The trailer headers read after the terminating zero-size chunk.
304    pub fn trailers(&self) -> &[(String, String)] {
305        &self.trailers
306    }
307
308    /// Consume the decoder, returning its trailer headers.
309    pub fn into_trailers(self) -> Vec<(String, String)> {
310        self.trailers
311    }
312}
313
314/// A fully decoded `aws-chunked` body held in memory.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct DecodedAwsChunked {
317    pub payload: Vec<u8>,
318    pub trailers: Vec<(String, String)>,
319}
320
321/// Decode an already-buffered `aws-chunked` body (the framing a streaming
322/// payload carries when it reaches a handler through the buffered path, e.g.
323/// S3 `PutObjectAnnotation`).
324pub fn decode_aws_chunked(body: &[u8]) -> Result<DecodedAwsChunked, MalformedChunk> {
325    let mut decoder = AwsChunkedDecoder::default();
326    let payload = decoder.feed(body)?;
327    Ok(DecodedAwsChunked {
328        payload,
329        trailers: decoder.into_trailers(),
330    })
331}
332
333/// Whether a request body carries the `aws-chunked` content-encoding (so the
334/// spool path must decode the framing). True when `Content-Encoding` lists
335/// `aws-chunked`, or `x-amz-content-sha256` is a `STREAMING-…` marker — both of
336/// which default modern S3 clients (aws-cli, boto3 >= 1.36, aws-crt) set.
337pub fn is_aws_chunked(headers: &http::HeaderMap) -> bool {
338    headers
339        .get("content-encoding")
340        .and_then(|v| v.to_str().ok())
341        .is_some_and(|v| {
342            v.split(',')
343                .any(|t| t.trim().eq_ignore_ascii_case("aws-chunked"))
344        })
345        || headers
346            .get("x-amz-content-sha256")
347            .and_then(|v| v.to_str().ok())
348            .is_some_and(|v| v.starts_with("STREAMING-"))
349}
350
351/// Strip the `aws-chunked` token from a client `Content-Encoding` so the stored
352/// object metadata reflects what AWS keeps (it consumes `aws-chunked` as a
353/// transfer detail; any remaining real encoding such as `gzip` is preserved).
354/// Returns `None` when nothing meaningful remains.
355pub fn strip_aws_chunked_encoding(content_encoding: Option<&str>) -> Option<String> {
356    let ce = content_encoding?;
357    let kept: Vec<&str> = ce
358        .split(',')
359        .map(|t| t.trim())
360        .filter(|t| !t.is_empty() && !t.eq_ignore_ascii_case("aws-chunked"))
361        .collect();
362    if kept.is_empty() {
363        None
364    } else {
365        Some(kept.join(", "))
366    }
367}
368
369/// Stream a request body to a tempfile on disk while computing its MD5
370/// and length on the fly. The body is **never** materialized into a
371/// single `Bytes` buffer; chunks flow from hyper -> Tokio file in
372/// constant memory. A 1 GiB PutObject moves through this function with
373/// peak resident memory bounded by hyper's per-frame buffer.
374///
375/// `dir` controls where the tempfile lands. S3 callers point this at
376/// the S3 object root so the eventual rename into the final storage
377/// path stays on the same filesystem and is a metadata-only move.
378/// Memory-mode callers can pass `None` for the system temp dir; the
379/// memory store reads the file back into bytes and unlinks it.
380///
381/// `aws_chunked` decodes the `Content-Encoding: aws-chunked` application-layer
382/// framing that default modern S3 clients apply (see [`AwsChunkedDecoder`]), so
383/// the spooled bytes, MD5/ETag, and size reflect the real payload — not the
384/// chunk-size/signature framing. Non-S3 callers (and raw `UNSIGNED-PAYLOAD`
385/// uploads) pass `false` and stream verbatim.
386pub async fn spool_request_stream(
387    stream: RequestBodyStream,
388    dir: Option<&std::path::Path>,
389    aws_chunked: bool,
390) -> Result<SpooledBody, AwsServiceError> {
391    spool_request_stream_observed(stream, dir, aws_chunked, &mut |_| {}).await
392}
393
394/// [`spool_request_stream`], additionally handing every decoded payload chunk
395/// to `observer` in order. Lets a caller fold extra digests (S3's flexible
396/// checksums) into the same single pass over the body instead of re-reading
397/// the spooled file.
398pub async fn spool_request_stream_observed(
399    stream: RequestBodyStream,
400    dir: Option<&std::path::Path>,
401    aws_chunked: bool,
402    observer: &mut (dyn FnMut(&[u8]) + Send),
403) -> Result<SpooledBody, AwsServiceError> {
404    use http_body_util::BodyExt;
405    use tokio::io::AsyncWriteExt;
406
407    let dir = dir.map(|d| d.to_path_buf());
408    if let Some(d) = dir.as_ref() {
409        // Best-effort create; an existing dir is fine.
410        let _ = tokio::fs::create_dir_all(d).await;
411    }
412
413    let mut builder = tempfile::Builder::new();
414    builder.prefix("fc-spool-");
415    let named = match dir.as_ref() {
416        Some(d) => builder.tempfile_in(d),
417        None => builder.tempfile(),
418    }
419    .map_err(|e| {
420        AwsServiceError::aws_error(
421            StatusCode::INTERNAL_SERVER_ERROR,
422            "InternalError",
423            format!("failed to create spool tempfile: {e}"),
424        )
425    })?;
426
427    // `into_temp_path` would auto-delete on drop. We keep the path and
428    // assume responsibility for either persisting or unlinking it.
429    let (std_file, temp_path) = named.into_parts();
430    // Persist to a stable PathBuf — `keep()` releases the
431    // delete-on-drop guard so the file outlives this function.
432    let path: PathBuf = temp_path.keep().map_err(|e| {
433        AwsServiceError::aws_error(
434            StatusCode::INTERNAL_SERVER_ERROR,
435            "InternalError",
436            format!("failed to persist spool tempfile: {e}"),
437        )
438    })?;
439
440    let mut file = tokio::fs::File::from_std(std_file);
441    let mut hasher = Md5::new();
442    let mut sha = sha2::Sha256::new();
443    let mut size: u64 = 0;
444    let mut body = stream;
445    let mut decoder = aws_chunked.then(AwsChunkedDecoder::default);
446    let mut http_trailers: Vec<(String, String)> = Vec::new();
447
448    // Cleanup helper: drop the file handle before unlinking so
449    // platforms that disallow removing an open file (Windows) still
450    // collect the partial spool. `drop(file)` closes the underlying
451    // OS handle synchronously.
452    async fn cleanup(file: tokio::fs::File, path: &std::path::Path) {
453        drop(file);
454        let _ = tokio::fs::remove_file(path).await;
455    }
456
457    loop {
458        match body.frame().await {
459            Some(Ok(frame)) => {
460                let frame = match frame.into_trailers() {
461                    Ok(trailers) => {
462                        for (name, value) in trailers.iter() {
463                            if let Ok(v) = value.to_str() {
464                                http_trailers
465                                    .push((name.as_str().to_string(), v.trim().to_string()));
466                            }
467                        }
468                        continue;
469                    }
470                    Err(frame) => frame,
471                };
472                if let Ok(raw) = frame.into_data() {
473                    if !raw.is_empty() {
474                        // Decode aws-chunked framing into the real payload when
475                        // the client used it; otherwise the frame IS the payload.
476                        let payload = match decoder.as_mut() {
477                            Some(d) => match d.feed(&raw) {
478                                Ok(decoded) => decoded,
479                                Err(_) => {
480                                    cleanup(file, &path).await;
481                                    return Err(AwsServiceError::aws_error(
482                                        StatusCode::BAD_REQUEST,
483                                        "InvalidChunkSizeError",
484                                        "Malformed aws-chunked request body",
485                                    ));
486                                }
487                            },
488                            None => raw.to_vec(),
489                        };
490                        if !payload.is_empty() {
491                            hasher.update(&payload);
492                            sha.update(&payload);
493                            observer(&payload);
494                            size += payload.len() as u64;
495                            if let Err(e) = file.write_all(&payload).await {
496                                cleanup(file, &path).await;
497                                return Err(AwsServiceError::aws_error(
498                                    StatusCode::INTERNAL_SERVER_ERROR,
499                                    "InternalError",
500                                    format!("failed to spool request body: {e}"),
501                                ));
502                            }
503                        }
504                    }
505                }
506            }
507            Some(Err(e)) => {
508                cleanup(file, &path).await;
509                return Err(stream_error_to_aws(&e.to_string()));
510            }
511            None => break,
512        }
513    }
514
515    if let Err(e) = file.flush().await {
516        cleanup(file, &path).await;
517        return Err(AwsServiceError::aws_error(
518            StatusCode::INTERNAL_SERVER_ERROR,
519            "InternalError",
520            format!("failed to flush spool tempfile: {e}"),
521        ));
522    }
523    drop(file);
524
525    let md5: [u8; 16] = hasher.finalize().into();
526    let sha256: [u8; 32] = sha.finalize().into();
527    let md5_hex = hex_lower(&md5);
528    let sha256_hex = hex_lower(&sha256);
529    let mut trailers = decoder
530        .map(AwsChunkedDecoder::into_trailers)
531        .unwrap_or_default();
532    trailers.extend(http_trailers);
533    Ok(SpooledBody {
534        path,
535        size,
536        md5_hex,
537        sha256_hex,
538        md5,
539        sha256,
540        trailers,
541    })
542}
543
544fn hex_lower(bytes: &[u8]) -> String {
545    const HEX: &[u8] = b"0123456789abcdef";
546    let mut out = String::with_capacity(bytes.len() * 2);
547    for b in bytes {
548        out.push(HEX[(b >> 4) as usize] as char);
549        out.push(HEX[(b & 0x0f) as usize] as char);
550    }
551    out
552}
553
554/// A response body. Most handlers return [`ResponseBody::Bytes`] built from
555/// an in-memory [`Bytes`] buffer; the [`File`](ResponseBody::File) variant
556/// exists so large disk-backed objects can be streamed straight from the
557/// filesystem to the HTTP body without being materialized into RAM. The file
558/// handle is opened by the service handler while it still holds the
559/// per-bucket read guard, so the reader sees a consistent inode even if a
560/// concurrent PUT/DELETE renames or unlinks the path before dispatch streams
561/// the body.
562#[derive(Debug)]
563pub enum ResponseBody {
564    Bytes(Bytes),
565    File { file: tokio::fs::File, size: u64 },
566}
567
568impl ResponseBody {
569    pub fn len(&self) -> u64 {
570        match self {
571            ResponseBody::Bytes(b) => b.len() as u64,
572            ResponseBody::File { size, .. } => *size,
573        }
574    }
575
576    pub fn is_empty(&self) -> bool {
577        self.len() == 0
578    }
579
580    /// Accessor that returns the bytes of a `Bytes` variant and panics for
581    /// `File`. Used by tests and by callers that know the response was built
582    /// from an in-memory buffer (JSON handlers, cross-service glue).
583    pub fn expect_bytes(&self) -> &[u8] {
584        match self {
585            ResponseBody::Bytes(b) => b,
586            ResponseBody::File { .. } => {
587                panic!("expect_bytes called on ResponseBody::File")
588            }
589        }
590    }
591}
592
593impl Default for ResponseBody {
594    fn default() -> Self {
595        ResponseBody::Bytes(Bytes::new())
596    }
597}
598
599impl From<Bytes> for ResponseBody {
600    fn from(b: Bytes) -> Self {
601        ResponseBody::Bytes(b)
602    }
603}
604
605impl From<Vec<u8>> for ResponseBody {
606    fn from(v: Vec<u8>) -> Self {
607        ResponseBody::Bytes(Bytes::from(v))
608    }
609}
610
611impl From<&'static [u8]> for ResponseBody {
612    fn from(s: &'static [u8]) -> Self {
613        ResponseBody::Bytes(Bytes::from_static(s))
614    }
615}
616
617impl From<String> for ResponseBody {
618    fn from(s: String) -> Self {
619        ResponseBody::Bytes(Bytes::from(s))
620    }
621}
622
623impl From<&'static str> for ResponseBody {
624    fn from(s: &'static str) -> Self {
625        ResponseBody::Bytes(Bytes::from_static(s.as_bytes()))
626    }
627}
628
629impl PartialEq<Bytes> for ResponseBody {
630    fn eq(&self, other: &Bytes) -> bool {
631        match self {
632            ResponseBody::Bytes(b) => b == other,
633            ResponseBody::File { .. } => false,
634        }
635    }
636}
637
638/// A response from a service handler.
639pub struct AwsResponse {
640    pub status: StatusCode,
641    pub content_type: String,
642    pub body: ResponseBody,
643    pub headers: HeaderMap,
644}
645
646impl AwsResponse {
647    pub fn xml(status: StatusCode, body: impl Into<Bytes>) -> Self {
648        Self {
649            status,
650            content_type: "text/xml".to_string(),
651            body: ResponseBody::Bytes(body.into()),
652            headers: HeaderMap::new(),
653        }
654    }
655
656    pub fn json(status: StatusCode, body: impl Into<Bytes>) -> Self {
657        Self {
658            status,
659            content_type: "application/x-amz-json-1.1".to_string(),
660            body: ResponseBody::Bytes(body.into()),
661            headers: HeaderMap::new(),
662        }
663    }
664
665    /// Build a JSON response from a `serde_json::Value` with an explicit status.
666    ///
667    /// Serialization of an in-memory `Value` cannot fail — it has no cycles and
668    /// no custom serializers — so the inner `to_vec` is documented as infallible
669    /// rather than left as a bare `unwrap()`.
670    pub fn json_value(status: StatusCode, value: serde_json::Value) -> Self {
671        Self::json(
672            status,
673            serde_json::to_vec(&value).expect("serde_json::Value serialization is infallible"),
674        )
675    }
676
677    /// Convenience constructor for a 200 OK JSON response from a `serde_json::Value`.
678    pub fn ok_json(value: serde_json::Value) -> Self {
679        Self::json_value(StatusCode::OK, value)
680    }
681}
682
683/// Error returned by service handlers.
684#[derive(Debug, thiserror::Error)]
685pub enum AwsServiceError {
686    #[error("service not found: {service}")]
687    ServiceNotFound { service: String },
688
689    #[error("action {action} not implemented for service {service}")]
690    ActionNotImplemented { service: String, action: String },
691
692    #[error("{code}: {message}")]
693    AwsError {
694        status: StatusCode,
695        code: String,
696        message: String,
697        /// Additional key-value pairs to include in the error XML (e.g., BucketName, Key, Condition).
698        extra_fields: Vec<(String, String)>,
699        /// Additional HTTP headers to include in the error response.
700        headers: Vec<(String, String)>,
701    },
702}
703
704impl AwsServiceError {
705    pub fn action_not_implemented(service: &str, action: &str) -> Self {
706        Self::ActionNotImplemented {
707            service: service.to_string(),
708            action: action.to_string(),
709        }
710    }
711
712    pub fn aws_error(
713        status: StatusCode,
714        code: impl Into<String>,
715        message: impl Into<String>,
716    ) -> Self {
717        Self::AwsError {
718            status,
719            code: code.into(),
720            message: message.into(),
721            extra_fields: Vec::new(),
722            headers: Vec::new(),
723        }
724    }
725
726    pub fn aws_error_with_fields(
727        status: StatusCode,
728        code: impl Into<String>,
729        message: impl Into<String>,
730        extra_fields: Vec<(String, String)>,
731    ) -> Self {
732        Self::AwsError {
733            status,
734            code: code.into(),
735            message: message.into(),
736            extra_fields,
737            headers: Vec::new(),
738        }
739    }
740
741    pub fn aws_error_with_headers(
742        status: StatusCode,
743        code: impl Into<String>,
744        message: impl Into<String>,
745        headers: Vec<(String, String)>,
746    ) -> Self {
747        Self::AwsError {
748            status,
749            code: code.into(),
750            message: message.into(),
751            extra_fields: Vec::new(),
752            headers,
753        }
754    }
755
756    pub fn extra_fields(&self) -> &[(String, String)] {
757        match self {
758            Self::AwsError { extra_fields, .. } => extra_fields,
759            _ => &[],
760        }
761    }
762
763    pub fn status(&self) -> StatusCode {
764        match self {
765            Self::ServiceNotFound { .. } => StatusCode::BAD_REQUEST,
766            Self::ActionNotImplemented { .. } => StatusCode::NOT_IMPLEMENTED,
767            Self::AwsError { status, .. } => *status,
768        }
769    }
770
771    pub fn code(&self) -> &str {
772        match self {
773            Self::ServiceNotFound { .. } => "UnknownService",
774            Self::ActionNotImplemented { .. } => "InvalidAction",
775            Self::AwsError { code, .. } => code,
776        }
777    }
778
779    pub fn message(&self) -> String {
780        match self {
781            Self::ServiceNotFound { service } => format!("service not found: {service}"),
782            Self::ActionNotImplemented { service, action } => {
783                format!("action {action} not implemented for service {service}")
784            }
785            Self::AwsError { message, .. } => message.clone(),
786        }
787    }
788
789    pub fn response_headers(&self) -> &[(String, String)] {
790        match self {
791            Self::AwsError { headers, .. } => headers,
792            _ => &[],
793        }
794    }
795}
796
797/// Trait that every AWS service implements.
798#[async_trait]
799pub trait AwsService: Send + Sync {
800    /// The AWS service identifier (e.g., "sqs", "sns", "sts", "events", "ssm").
801    fn service_name(&self) -> &str;
802
803    /// Handle an incoming request.
804    async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError>;
805
806    /// List of actions this service supports (for introspection).
807    fn supported_actions(&self) -> &[&str];
808
809    /// Whether this service participates in opt-in IAM enforcement
810    /// (`FAKECLOUD_IAM=soft|strict`).
811    ///
812    /// Defaults to `false`: unless a service has a full
813    /// `iam_action_for` implementation covering every operation it
814    /// supports plus resource-ARN extractors, it's silently skipped when
815    /// IAM enforcement is on. The startup log enumerates which services
816    /// are enforced and which are not so users always know the current
817    /// enforcement surface.
818    ///
819    /// Phase 1 contract: a service that returns `true` here MUST also
820    /// provide a fully populated [`AwsService::iam_action_for`]
821    /// implementation covering every action it advertises. Returning
822    /// `true` without the action mapping is a programming bug.
823    fn iam_enforceable(&self) -> bool {
824        false
825    }
826
827    /// Derive the IAM action + resource ARN for an incoming request.
828    ///
829    /// Only called when [`AwsService::iam_enforceable`] returns `true`
830    /// and IAM enforcement is enabled. Services must map every action
831    /// they implement; returning `None` for a covered action causes the
832    /// evaluator to skip the request and flag it via the
833    /// `fakecloud::iam::audit` tracing target so gaps are visible in
834    /// soft mode.
835    ///
836    /// The `IamAction.resource` is built from `request.principal`'s
837    /// account id (not global config) so multi-account isolation
838    /// (#381) works once per-account state partitioning lands.
839    fn iam_action_for(&self, _request: &AwsRequest) -> Option<crate::auth::IamAction> {
840        None
841    }
842
843    /// Every IAM authorization an incoming request needs.
844    ///
845    /// Most operations act on one resource and need one action, which is
846    /// what the default returns ([`AwsService::iam_action_for`]). Some need
847    /// several: a batch or transaction naming several resources needs the
848    /// action on each, and an operation can require more than one action
849    /// (DynamoDB's `CreateTable` with `Tags` also needs `TagResource`).
850    /// Dispatch evaluates every action returned and denies the request if
851    /// any of them is denied. An empty list means the operation has no
852    /// mapping, which strict enforcement denies.
853    fn iam_actions_for(&self, request: &AwsRequest) -> Vec<crate::auth::IamAction> {
854        self.iam_action_for(request).into_iter().collect()
855    }
856
857    /// Whether the request creates its resource in the CALLER's account, so
858    /// that whatever currently holds the resource's name elsewhere has no say
859    /// in authorizing it. S3's `CreateBucket` is the case: bucket names are
860    /// global, and creating a name another account owns is authorized against
861    /// the caller's own policies (then answered `BucketAlreadyExists`), never
862    /// against the other account's bucket policy. Dispatch then evaluates the
863    /// request as same-account with no resource policy. Default `false`.
864    fn iam_resource_in_caller_account(&self, _request: &AwsRequest) -> bool {
865        false
866    }
867
868    /// Derive service-specific IAM condition keys for an incoming request.
869    ///
870    /// Called right after [`AwsService::iam_action_for`] when IAM
871    /// enforcement is enabled. The returned map is merged into the
872    /// [`crate::auth::ConditionContext::service_keys`] before the
873    /// evaluator runs, so policies can reference keys like `s3:prefix`
874    /// or `sns:Protocol` the same way they reference global keys.
875    ///
876    /// Keys MUST be in the full `"service:key"` form, lowercased
877    /// (e.g. `"s3:prefix"`), matching the case-insensitive lookup in
878    /// [`crate::auth::ConditionContext::lookup`]. Extractors should
879    /// only emit keys they can populate with confidence; anything
880    /// ambiguous or unimplemented should be skipped with a
881    /// `tracing::debug!(target: "fakecloud::iam::audit", ...)` so
882    /// condition evaluation safe-fails to "doesn't apply" rather than
883    /// "matches".
884    ///
885    /// Default impl returns an empty map: services that haven't been
886    /// plumbed yet behave exactly as before.
887    fn iam_condition_keys_for(
888        &self,
889        _request: &AwsRequest,
890        _action: &crate::auth::IamAction,
891    ) -> BTreeMap<String, Vec<String>> {
892        BTreeMap::new()
893    }
894
895    /// Return the tags on the resource identified by `resource_arn`.
896    ///
897    /// Called at dispatch time when IAM enforcement is enabled, right
898    /// after [`AwsService::iam_action_for`]. The returned map populates
899    /// `aws:ResourceTag/<key>` condition keys so policies can gate
900    /// access based on the target resource's tags.
901    ///
902    /// Return `None` to signal that this service does not (yet) support
903    /// resource-tag ABAC — dispatch will emit a debug audit log and
904    /// skip `aws:ResourceTag/*` evaluation. Return `Some(empty map)`
905    /// when the resource exists but has no tags.
906    fn resource_tags_for(
907        &self,
908        _resource_arn: &str,
909    ) -> Option<std::collections::HashMap<String, String>> {
910        None
911    }
912
913    /// Extract tags being sent in the request (e.g. on CreateQueue,
914    /// PutObject with `x-amz-tagging`, TagResource).
915    ///
916    /// The returned map populates `aws:RequestTag/<key>` and
917    /// `aws:TagKeys` condition keys. Return `None` when the service
918    /// does not (yet) support request-tag extraction — dispatch skips
919    /// `aws:RequestTag/*` / `aws:TagKeys` evaluation with a debug log.
920    /// Return `Some(empty map)` when the request legitimately carries
921    /// no tags.
922    fn request_tags_from(
923        &self,
924        _request: &AwsRequest,
925        _action: &str,
926    ) -> Option<std::collections::HashMap<String, String>> {
927        None
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use crate::auth::IamAction;
935    use async_trait::async_trait;
936
937    /// Build a signed aws-chunked body for `payload`, split into chunks of
938    /// `chunk_size`, terminated by a 0-chunk + a trailer + final CRLF.
939    fn aws_chunked_body(payload: &[u8], chunk_size: usize, with_trailer: bool) -> Vec<u8> {
940        let sig = "0".repeat(64);
941        let mut out = Vec::new();
942        for c in payload.chunks(chunk_size.max(1)) {
943            out.extend_from_slice(format!("{:x};chunk-signature={sig}\r\n", c.len()).as_bytes());
944            out.extend_from_slice(c);
945            out.extend_from_slice(b"\r\n");
946        }
947        out.extend_from_slice(format!("0;chunk-signature={sig}\r\n").as_bytes());
948        if with_trailer {
949            out.extend_from_slice(b"x-amz-checksum-crc32:AAAAAA==\r\n");
950        }
951        out.extend_from_slice(b"\r\n");
952        out
953    }
954
955    fn decode_all(body: &[u8], feed_size: usize) -> Vec<u8> {
956        let mut d = AwsChunkedDecoder::default();
957        let mut out = Vec::new();
958        for frame in body.chunks(feed_size.max(1)) {
959            out.extend(d.feed(frame).expect("valid chunked body"));
960        }
961        out
962    }
963
964    #[test]
965    fn aws_chunked_decoder_roundtrips_across_frame_boundaries() {
966        let payload: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
967        // Chunked with 1 KiB data chunks; with and without a trailer.
968        for with_trailer in [false, true] {
969            let body = aws_chunked_body(&payload, 1024, with_trailer);
970            // Network frames don't align to chunk boundaries: try several sizes,
971            // including 1 byte at a time and a single whole-body frame.
972            for feed in [1usize, 7, 64, 1000, body.len()] {
973                let decoded = decode_all(&body, feed);
974                assert_eq!(decoded, payload, "feed={feed} trailer={with_trailer}");
975            }
976        }
977    }
978
979    #[test]
980    fn aws_chunked_decoder_collects_trailers_across_frame_boundaries() {
981        let payload: Vec<u8> = (0..3000u32).map(|i| (i % 251) as u8).collect();
982        let body = aws_chunked_body(&payload, 1024, true);
983        for feed in [1usize, 5, 333, body.len()] {
984            let mut d = AwsChunkedDecoder::default();
985            for frame in body.chunks(feed) {
986                d.feed(frame).unwrap();
987            }
988            assert_eq!(
989                d.trailers(),
990                &[("x-amz-checksum-crc32".to_string(), "AAAAAA==".to_string())],
991                "feed={feed}"
992            );
993        }
994    }
995
996    #[test]
997    fn aws_chunked_trailer_signature_and_optional_newline_are_handled() {
998        // Signed trailer form: the checksum line (with the optional bare `\n`
999        // some clients emit), then the trailer signature, then the final CRLF.
1000        let body = b"5\r\nhello\r\n0\r\nX-Amz-Checksum-Sha256: abc= \n\r\n";
1001        let decoded = decode_aws_chunked(body).unwrap();
1002        assert_eq!(decoded.payload, b"hello");
1003        assert_eq!(
1004            decoded.trailers,
1005            vec![("x-amz-checksum-sha256".to_string(), "abc=".to_string())]
1006        );
1007        let signed =
1008            b"3\r\nabc\r\n0\r\nx-amz-checksum-crc32c:Zm9v\r\nx-amz-trailer-signature:dead\r\n\r\n";
1009        let decoded = decode_aws_chunked(signed).unwrap();
1010        assert_eq!(decoded.payload, b"abc");
1011        assert_eq!(
1012            decoded.trailers,
1013            vec![("x-amz-checksum-crc32c".to_string(), "Zm9v".to_string())]
1014        );
1015        assert!(decode_aws_chunked(b"zz\r\nabc").is_err());
1016    }
1017
1018    #[tokio::test]
1019    async fn spool_observer_sees_every_decoded_byte_and_trailers_are_returned() {
1020        let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
1021        let body = aws_chunked_body(&payload, 1024, true);
1022        let mut seen = Vec::new();
1023        let spooled =
1024            spool_request_stream_observed(axum::body::Body::from(body), None, true, &mut |chunk| {
1025                seen.extend_from_slice(chunk)
1026            })
1027            .await
1028            .expect("spool ok");
1029        assert_eq!(seen, payload);
1030        assert_eq!(
1031            spooled.trailers,
1032            vec![("x-amz-checksum-crc32".to_string(), "AAAAAA==".to_string())]
1033        );
1034        let _ = std::fs::remove_file(&spooled.path);
1035    }
1036
1037    #[test]
1038    fn aws_chunked_decoder_handles_empty_payload() {
1039        let body = aws_chunked_body(b"", 1024, false);
1040        assert_eq!(decode_all(&body, 3), Vec::<u8>::new());
1041    }
1042
1043    fn sha256_hex(bytes: &[u8]) -> String {
1044        let mut h = sha2::Sha256::new();
1045        h.update(bytes);
1046        hex_lower(&h.finalize())
1047    }
1048
1049    #[tokio::test]
1050    async fn spool_computes_sha256_over_plain_payload() {
1051        let payload = b"hello world".to_vec();
1052        let spooled = spool_request_stream(axum::body::Body::from(payload.clone()), None, false)
1053            .await
1054            .expect("spool ok");
1055        assert_eq!(spooled.size, payload.len() as u64);
1056        assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
1057        let _ = std::fs::remove_file(&spooled.path);
1058    }
1059
1060    #[tokio::test]
1061    async fn spool_sha256_is_over_decoded_aws_chunked_payload() {
1062        // The header the client sends over aws-chunked framing is a STREAMING
1063        // marker, but the spool's sha256 must still describe the DECODED bytes
1064        // (so the S3 layer never compares against the framed wire form).
1065        let payload: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
1066        let body = aws_chunked_body(&payload, 1024, true);
1067        let spooled = spool_request_stream(axum::body::Body::from(body), None, true)
1068            .await
1069            .expect("spool ok");
1070        assert_eq!(spooled.size, payload.len() as u64);
1071        assert_eq!(spooled.sha256_hex, sha256_hex(&payload));
1072        let _ = std::fs::remove_file(&spooled.path);
1073    }
1074
1075    #[test]
1076    fn aws_chunked_decoder_rejects_bad_size_line() {
1077        let mut d = AwsChunkedDecoder::default();
1078        assert!(d.feed(b"zz;chunk-signature=x\r\n").is_err());
1079    }
1080
1081    #[test]
1082    fn is_aws_chunked_detects_streaming_markers() {
1083        let mut h = http::HeaderMap::new();
1084        assert!(!is_aws_chunked(&h));
1085        h.insert("content-encoding", "aws-chunked".parse().unwrap());
1086        assert!(is_aws_chunked(&h));
1087        let mut h2 = http::HeaderMap::new();
1088        h2.insert(
1089            "x-amz-content-sha256",
1090            "STREAMING-AWS4-HMAC-SHA256-PAYLOAD".parse().unwrap(),
1091        );
1092        assert!(is_aws_chunked(&h2));
1093        // gzip without aws-chunked must NOT trigger decoding.
1094        let mut h3 = http::HeaderMap::new();
1095        h3.insert("content-encoding", "gzip".parse().unwrap());
1096        assert!(!is_aws_chunked(&h3));
1097    }
1098
1099    #[test]
1100    fn strip_aws_chunked_keeps_real_encoding() {
1101        assert_eq!(strip_aws_chunked_encoding(Some("aws-chunked")), None);
1102        assert_eq!(
1103            strip_aws_chunked_encoding(Some("aws-chunked, gzip")).as_deref(),
1104            Some("gzip")
1105        );
1106        assert_eq!(
1107            strip_aws_chunked_encoding(Some("gzip")).as_deref(),
1108            Some("gzip")
1109        );
1110        assert_eq!(strip_aws_chunked_encoding(None), None);
1111    }
1112
1113    struct DefaultService;
1114
1115    #[async_trait]
1116    impl AwsService for DefaultService {
1117        fn service_name(&self) -> &str {
1118            "default"
1119        }
1120        async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1121            unreachable!()
1122        }
1123        fn supported_actions(&self) -> &[&str] {
1124            &[]
1125        }
1126    }
1127
1128    struct PopulatedService;
1129
1130    #[async_trait]
1131    impl AwsService for PopulatedService {
1132        fn service_name(&self) -> &str {
1133            "populated"
1134        }
1135        async fn handle(&self, _request: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1136            unreachable!()
1137        }
1138        fn supported_actions(&self) -> &[&str] {
1139            &[]
1140        }
1141        fn iam_condition_keys_for(
1142            &self,
1143            _request: &AwsRequest,
1144            _action: &IamAction,
1145        ) -> BTreeMap<String, Vec<String>> {
1146            let mut m = BTreeMap::new();
1147            m.insert("s3:prefix".to_string(), vec!["logs/".to_string()]);
1148            m
1149        }
1150    }
1151
1152    fn sample_request() -> AwsRequest {
1153        AwsRequest {
1154            service: "default".into(),
1155            action: "Noop".into(),
1156            region: "us-east-1".into(),
1157            account_id: "123456789012".into(),
1158            request_id: "req-1".into(),
1159            headers: HeaderMap::new(),
1160            query_params: HashMap::new(),
1161            body: Bytes::new(),
1162            body_stream: parking_lot::Mutex::new(None),
1163            path_segments: vec![],
1164            raw_path: "/".into(),
1165            raw_query: String::new(),
1166            method: Method::GET,
1167            is_query_protocol: false,
1168            access_key_id: None,
1169            principal: None,
1170        }
1171    }
1172
1173    fn sample_action() -> IamAction {
1174        IamAction {
1175            service: "s3",
1176            action: "ListBucket",
1177            resource: "arn:aws:s3:::my-bucket".to_string(),
1178        }
1179    }
1180
1181    #[test]
1182    fn iam_condition_keys_for_default_is_empty() {
1183        let svc = DefaultService;
1184        let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1185        assert!(keys.is_empty());
1186    }
1187
1188    #[test]
1189    fn iam_condition_keys_for_override_returns_map() {
1190        let svc = PopulatedService;
1191        let keys = svc.iam_condition_keys_for(&sample_request(), &sample_action());
1192        assert_eq!(keys.get("s3:prefix"), Some(&vec!["logs/".to_string()]));
1193    }
1194
1195    #[test]
1196    fn response_body_len_and_is_empty_for_bytes() {
1197        let body: ResponseBody = Bytes::from_static(b"hello").into();
1198        assert_eq!(body.len(), 5);
1199        assert!(!body.is_empty());
1200        let empty: ResponseBody = ResponseBody::default();
1201        assert!(empty.is_empty());
1202    }
1203
1204    #[test]
1205    fn response_body_from_vec_and_string_and_str() {
1206        let from_vec: ResponseBody = vec![1u8, 2, 3].into();
1207        assert_eq!(from_vec.expect_bytes(), &[1, 2, 3][..]);
1208        let from_string: ResponseBody = String::from("hi").into();
1209        assert_eq!(from_string.expect_bytes(), b"hi");
1210        let from_str: ResponseBody = "hey".into();
1211        assert_eq!(from_str.expect_bytes(), b"hey");
1212        let from_static: ResponseBody = (b"123" as &'static [u8]).into();
1213        assert_eq!(from_static.expect_bytes(), b"123");
1214    }
1215
1216    #[test]
1217    fn response_body_partial_eq_bytes() {
1218        let body: ResponseBody = Bytes::from_static(b"x").into();
1219        assert!(body == Bytes::from_static(b"x"));
1220        assert!(!(body == Bytes::from_static(b"y")));
1221    }
1222
1223    #[test]
1224    fn aws_request_json_body_empty_returns_null() {
1225        let req = sample_request();
1226        assert_eq!(req.json_body(), serde_json::Value::Null);
1227    }
1228
1229    #[test]
1230    fn aws_request_json_body_parses_valid() {
1231        let mut req = sample_request();
1232        req.body = Bytes::from_static(br#"{"a":1}"#);
1233        assert_eq!(req.json_body(), serde_json::json!({"a": 1}));
1234    }
1235
1236    #[test]
1237    fn aws_response_xml_constructor() {
1238        let resp = AwsResponse::xml(StatusCode::OK, Bytes::from_static(b"<ok/>"));
1239        assert_eq!(resp.status, StatusCode::OK);
1240        assert_eq!(resp.content_type, "text/xml");
1241    }
1242
1243    #[test]
1244    fn aws_response_json_constructor() {
1245        let resp = AwsResponse::json(StatusCode::CREATED, "{}");
1246        assert_eq!(resp.status, StatusCode::CREATED);
1247        assert_eq!(resp.content_type, "application/x-amz-json-1.1");
1248    }
1249
1250    #[test]
1251    fn aws_response_ok_json_helper() {
1252        let resp = AwsResponse::ok_json(serde_json::json!({"ok": true}));
1253        assert_eq!(resp.status, StatusCode::OK);
1254        assert!(resp.body.expect_bytes().starts_with(b"{"));
1255    }
1256
1257    #[test]
1258    fn aws_error_service_not_found_fields() {
1259        let err = AwsServiceError::ServiceNotFound {
1260            service: "sqs".to_string(),
1261        };
1262        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
1263        assert_eq!(err.code(), "UnknownService");
1264        assert!(err.message().contains("sqs"));
1265        assert!(err.extra_fields().is_empty());
1266        assert!(err.response_headers().is_empty());
1267    }
1268
1269    #[test]
1270    fn aws_error_action_not_implemented_fields() {
1271        let err = AwsServiceError::action_not_implemented("sns", "FutureAction");
1272        assert_eq!(err.status(), StatusCode::NOT_IMPLEMENTED);
1273        assert_eq!(err.code(), "InvalidAction");
1274        assert!(err.message().contains("FutureAction"));
1275        assert!(err.message().contains("sns"));
1276    }
1277
1278    #[test]
1279    fn aws_error_aws_error_helpers() {
1280        let e = AwsServiceError::aws_error(StatusCode::FORBIDDEN, "Denied", "no");
1281        assert_eq!(e.status(), StatusCode::FORBIDDEN);
1282        assert_eq!(e.code(), "Denied");
1283        assert_eq!(e.message(), "no");
1284
1285        let fields = vec![("Bucket".to_string(), "b".to_string())];
1286        let ef = AwsServiceError::aws_error_with_fields(
1287            StatusCode::NOT_FOUND,
1288            "Missing",
1289            "gone",
1290            fields.clone(),
1291        );
1292        assert_eq!(ef.extra_fields(), fields.as_slice());
1293
1294        let hdrs = vec![("X-Retry".to_string(), "1".to_string())];
1295        let eh = AwsServiceError::aws_error_with_headers(
1296            StatusCode::TOO_MANY_REQUESTS,
1297            "Throttled",
1298            "slow",
1299            hdrs.clone(),
1300        );
1301        assert_eq!(eh.response_headers(), hdrs.as_slice());
1302    }
1303
1304    #[test]
1305    #[should_panic(expected = "expect_bytes called on ResponseBody::File")]
1306    fn response_body_expect_bytes_panics_on_file() {
1307        let f = std::fs::File::create(std::env::temp_dir().join("fc-test-expect-file")).unwrap();
1308        let async_f = tokio::fs::File::from_std(f);
1309        let body = ResponseBody::File {
1310            file: async_f,
1311            size: 0,
1312        };
1313        let _ = body.expect_bytes();
1314    }
1315}