Skip to main content

fakecloud_core/
service.rs

1use async_trait::async_trait;
2use bytes::Bytes;
3use http::{HeaderMap, Method, StatusCode};
4use std::collections::HashMap;
5
6use crate::auth::Principal;
7
8/// A parsed AWS request.
9#[derive(Debug)]
10pub struct AwsRequest {
11    pub service: String,
12    pub action: String,
13    pub region: String,
14    pub account_id: String,
15    pub request_id: String,
16    pub headers: HeaderMap,
17    pub query_params: HashMap<String, String>,
18    pub body: Bytes,
19    pub path_segments: Vec<String>,
20    /// The raw URI path, before splitting into segments.
21    pub raw_path: String,
22    /// The raw URI query string (everything after `?`), preserving repeated keys.
23    pub raw_query: String,
24    pub method: Method,
25    /// Whether this request came via Query (form-encoded) or JSON protocol.
26    pub is_query_protocol: bool,
27    /// The access key ID from the SigV4 Authorization header, if present.
28    pub access_key_id: Option<String>,
29    /// The resolved caller identity. `None` when the credential is unknown
30    /// or the caller used the reserved root-bypass credentials. Populated
31    /// by dispatch via the configured [`crate::auth::CredentialResolver`]
32    /// so service handlers can make identity-based decisions (e.g.
33    /// `GetCallerIdentity`, IAM enforcement) without re-parsing the
34    /// Authorization header.
35    pub principal: Option<Principal>,
36}
37
38impl AwsRequest {
39    /// Parse the request body as JSON, returning `Value::Null` on failure.
40    pub fn json_body(&self) -> serde_json::Value {
41        serde_json::from_slice(&self.body).unwrap_or(serde_json::Value::Null)
42    }
43}
44
45/// A response body. Most handlers return [`ResponseBody::Bytes`] built from
46/// an in-memory [`Bytes`] buffer; the [`File`](ResponseBody::File) variant
47/// exists so large disk-backed objects can be streamed straight from the
48/// filesystem to the HTTP body without being materialized into RAM. The file
49/// handle is opened by the service handler while it still holds the
50/// per-bucket read guard, so the reader sees a consistent inode even if a
51/// concurrent PUT/DELETE renames or unlinks the path before dispatch streams
52/// the body.
53#[derive(Debug)]
54pub enum ResponseBody {
55    Bytes(Bytes),
56    File { file: tokio::fs::File, size: u64 },
57}
58
59impl ResponseBody {
60    pub fn len(&self) -> u64 {
61        match self {
62            ResponseBody::Bytes(b) => b.len() as u64,
63            ResponseBody::File { size, .. } => *size,
64        }
65    }
66
67    pub fn is_empty(&self) -> bool {
68        self.len() == 0
69    }
70
71    /// Accessor that returns the bytes of a `Bytes` variant and panics for
72    /// `File`. Used by tests and by callers that know the response was built
73    /// from an in-memory buffer (JSON handlers, cross-service glue).
74    pub fn expect_bytes(&self) -> &[u8] {
75        match self {
76            ResponseBody::Bytes(b) => b,
77            ResponseBody::File { .. } => {
78                panic!("expect_bytes called on ResponseBody::File")
79            }
80        }
81    }
82}
83
84impl Default for ResponseBody {
85    fn default() -> Self {
86        ResponseBody::Bytes(Bytes::new())
87    }
88}
89
90impl From<Bytes> for ResponseBody {
91    fn from(b: Bytes) -> Self {
92        ResponseBody::Bytes(b)
93    }
94}
95
96impl From<Vec<u8>> for ResponseBody {
97    fn from(v: Vec<u8>) -> Self {
98        ResponseBody::Bytes(Bytes::from(v))
99    }
100}
101
102impl From<&'static [u8]> for ResponseBody {
103    fn from(s: &'static [u8]) -> Self {
104        ResponseBody::Bytes(Bytes::from_static(s))
105    }
106}
107
108impl From<String> for ResponseBody {
109    fn from(s: String) -> Self {
110        ResponseBody::Bytes(Bytes::from(s))
111    }
112}
113
114impl From<&'static str> for ResponseBody {
115    fn from(s: &'static str) -> Self {
116        ResponseBody::Bytes(Bytes::from_static(s.as_bytes()))
117    }
118}
119
120impl PartialEq<Bytes> for ResponseBody {
121    fn eq(&self, other: &Bytes) -> bool {
122        match self {
123            ResponseBody::Bytes(b) => b == other,
124            ResponseBody::File { .. } => false,
125        }
126    }
127}
128
129/// A response from a service handler.
130pub struct AwsResponse {
131    pub status: StatusCode,
132    pub content_type: String,
133    pub body: ResponseBody,
134    pub headers: HeaderMap,
135}
136
137impl AwsResponse {
138    pub fn xml(status: StatusCode, body: impl Into<Bytes>) -> Self {
139        Self {
140            status,
141            content_type: "text/xml".to_string(),
142            body: ResponseBody::Bytes(body.into()),
143            headers: HeaderMap::new(),
144        }
145    }
146
147    pub fn json(status: StatusCode, body: impl Into<Bytes>) -> Self {
148        Self {
149            status,
150            content_type: "application/x-amz-json-1.1".to_string(),
151            body: ResponseBody::Bytes(body.into()),
152            headers: HeaderMap::new(),
153        }
154    }
155
156    /// Convenience constructor for a 200 OK JSON response from a `serde_json::Value`.
157    pub fn ok_json(value: serde_json::Value) -> Self {
158        Self::json(StatusCode::OK, serde_json::to_vec(&value).unwrap())
159    }
160}
161
162/// Error returned by service handlers.
163#[derive(Debug, thiserror::Error)]
164pub enum AwsServiceError {
165    #[error("service not found: {service}")]
166    ServiceNotFound { service: String },
167
168    #[error("action {action} not implemented for service {service}")]
169    ActionNotImplemented { service: String, action: String },
170
171    #[error("{code}: {message}")]
172    AwsError {
173        status: StatusCode,
174        code: String,
175        message: String,
176        /// Additional key-value pairs to include in the error XML (e.g., BucketName, Key, Condition).
177        extra_fields: Vec<(String, String)>,
178        /// Additional HTTP headers to include in the error response.
179        headers: Vec<(String, String)>,
180    },
181}
182
183impl AwsServiceError {
184    pub fn action_not_implemented(service: &str, action: &str) -> Self {
185        Self::ActionNotImplemented {
186            service: service.to_string(),
187            action: action.to_string(),
188        }
189    }
190
191    pub fn aws_error(
192        status: StatusCode,
193        code: impl Into<String>,
194        message: impl Into<String>,
195    ) -> Self {
196        Self::AwsError {
197            status,
198            code: code.into(),
199            message: message.into(),
200            extra_fields: Vec::new(),
201            headers: Vec::new(),
202        }
203    }
204
205    pub fn aws_error_with_fields(
206        status: StatusCode,
207        code: impl Into<String>,
208        message: impl Into<String>,
209        extra_fields: Vec<(String, String)>,
210    ) -> Self {
211        Self::AwsError {
212            status,
213            code: code.into(),
214            message: message.into(),
215            extra_fields,
216            headers: Vec::new(),
217        }
218    }
219
220    pub fn aws_error_with_headers(
221        status: StatusCode,
222        code: impl Into<String>,
223        message: impl Into<String>,
224        headers: Vec<(String, String)>,
225    ) -> Self {
226        Self::AwsError {
227            status,
228            code: code.into(),
229            message: message.into(),
230            extra_fields: Vec::new(),
231            headers,
232        }
233    }
234
235    pub fn extra_fields(&self) -> &[(String, String)] {
236        match self {
237            Self::AwsError { extra_fields, .. } => extra_fields,
238            _ => &[],
239        }
240    }
241
242    pub fn status(&self) -> StatusCode {
243        match self {
244            Self::ServiceNotFound { .. } => StatusCode::BAD_REQUEST,
245            Self::ActionNotImplemented { .. } => StatusCode::NOT_IMPLEMENTED,
246            Self::AwsError { status, .. } => *status,
247        }
248    }
249
250    pub fn code(&self) -> &str {
251        match self {
252            Self::ServiceNotFound { .. } => "UnknownService",
253            Self::ActionNotImplemented { .. } => "InvalidAction",
254            Self::AwsError { code, .. } => code,
255        }
256    }
257
258    pub fn message(&self) -> String {
259        match self {
260            Self::ServiceNotFound { service } => format!("service not found: {service}"),
261            Self::ActionNotImplemented { service, action } => {
262                format!("action {action} not implemented for service {service}")
263            }
264            Self::AwsError { message, .. } => message.clone(),
265        }
266    }
267
268    pub fn response_headers(&self) -> &[(String, String)] {
269        match self {
270            Self::AwsError { headers, .. } => headers,
271            _ => &[],
272        }
273    }
274}
275
276/// Trait that every AWS service implements.
277#[async_trait]
278pub trait AwsService: Send + Sync {
279    /// The AWS service identifier (e.g., "sqs", "sns", "sts", "events", "ssm").
280    fn service_name(&self) -> &str;
281
282    /// Handle an incoming request.
283    async fn handle(&self, request: AwsRequest) -> Result<AwsResponse, AwsServiceError>;
284
285    /// List of actions this service supports (for introspection).
286    fn supported_actions(&self) -> &[&str];
287
288    /// Whether this service participates in opt-in IAM enforcement
289    /// (`FAKECLOUD_IAM=soft|strict`).
290    ///
291    /// Defaults to `false`: unless a service has a full
292    /// `iam_action_for` implementation covering every operation it
293    /// supports plus resource-ARN extractors, it's silently skipped when
294    /// IAM enforcement is on. The startup log enumerates which services
295    /// are enforced and which are not so users always know the current
296    /// enforcement surface.
297    ///
298    /// Phase 1 contract: a service that returns `true` here MUST also
299    /// provide a fully populated [`AwsService::iam_action_for`]
300    /// implementation covering every action it advertises. Returning
301    /// `true` without the action mapping is a programming bug.
302    fn iam_enforceable(&self) -> bool {
303        false
304    }
305
306    /// Derive the IAM action + resource ARN for an incoming request.
307    ///
308    /// Only called when [`AwsService::iam_enforceable`] returns `true`
309    /// and IAM enforcement is enabled. Services must map every action
310    /// they implement; returning `None` for a covered action causes the
311    /// evaluator to skip the request and flag it via the
312    /// `fakecloud::iam::audit` tracing target so gaps are visible in
313    /// soft mode.
314    ///
315    /// The `IamAction.resource` is built from `request.principal`'s
316    /// account id (not global config) so multi-account isolation
317    /// (#381) works once per-account state partitioning lands.
318    fn iam_action_for(&self, _request: &AwsRequest) -> Option<crate::auth::IamAction> {
319        None
320    }
321}