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}