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