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