Skip to main content

fakecloud_lambda/service/
mod.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use chrono::Utc;
6use http::{Method, StatusCode};
7use serde_json::{json, Value};
8use sha2::{Digest, Sha256};
9use tokio::sync::Mutex as AsyncMutex;
10
11use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
12use fakecloud_persistence::SnapshotStore;
13
14use crate::runtime::ContainerRuntime;
15use crate::state::{
16    EventSourceMapping, LambdaFunction, LambdaSnapshot, LambdaState, SharedLambdaState,
17    LAMBDA_SNAPSHOT_SCHEMA_VERSION,
18};
19
20fn invalid_param(msg: impl Into<String>) -> AwsServiceError {
21    AwsServiceError::aws_error(
22        StatusCode::BAD_REQUEST,
23        "InvalidParameterValueException",
24        msg,
25    )
26}
27
28fn check_len(field: &str, v: &str, min: usize, max: usize) -> Result<(), AwsServiceError> {
29    if v.len() < min || v.len() > max {
30        return Err(invalid_param(format!(
31            "{field} length must be in [{min},{max}], got {}",
32            v.len()
33        )));
34    }
35    Ok(())
36}
37
38fn check_optional_len(
39    field: &str,
40    v: Option<&str>,
41    min: usize,
42    max: usize,
43) -> Result<(), AwsServiceError> {
44    if let Some(s) = v {
45        check_len(field, s, min, max)?;
46    }
47    Ok(())
48}
49
50fn check_optional_int_range(
51    field: &str,
52    v: Option<i64>,
53    min: i64,
54    max: i64,
55) -> Result<(), AwsServiceError> {
56    if let Some(n) = v {
57        if n < min || n > max {
58            return Err(invalid_param(format!(
59                "{field} must be in [{min},{max}], got {n}"
60            )));
61        }
62    }
63    Ok(())
64}
65
66const LAMBDA_PUBLISH_TO_VALUES: &[&str] = &["LATEST_PUBLISHED"];
67
68// Trimmed to runtimes the SDK still mints; the full Smithy enum has 46
69// entries but only these are emitted by `aws-sdk-lambda` since the
70// older ones are deprecation-only and never surfaced via CreateFunction
71// in practice. Conformance probes use the model enum exhaustively, so
72// keep this list in sync with the Smithy model.
73const LAMBDA_RUNTIMES: &[&str] = &[
74    "nodejs",
75    "nodejs4.3",
76    "nodejs4.3-edge",
77    "nodejs6.10",
78    "nodejs8.10",
79    "nodejs10.x",
80    "nodejs12.x",
81    "nodejs14.x",
82    "nodejs16.x",
83    "nodejs18.x",
84    "nodejs20.x",
85    "nodejs22.x",
86    "nodejs24.x",
87    "java8",
88    "java8.al2",
89    "java11",
90    "java17",
91    "java21",
92    "java25",
93    "python2.7",
94    "python3.6",
95    "python3.7",
96    "python3.8",
97    "python3.9",
98    "python3.10",
99    "python3.11",
100    "python3.12",
101    "python3.13",
102    "python3.14",
103    "dotnetcore1.0",
104    "dotnetcore2.0",
105    "dotnetcore2.1",
106    "dotnetcore3.1",
107    "dotnet6",
108    "dotnet8",
109    "dotnet10",
110    "go1.x",
111    "ruby2.5",
112    "ruby2.7",
113    "ruby3.2",
114    "ruby3.3",
115    "ruby3.4",
116    "provided",
117    "provided.al2",
118    "provided.al2023",
119];
120
121fn check_optional_enum(
122    field: &str,
123    v: Option<&str>,
124    allowed: &[&str],
125) -> Result<(), AwsServiceError> {
126    if let Some(s) = v {
127        if !allowed.contains(&s) {
128            return Err(invalid_param(format!(
129                "{field} must be one of the enum values, got '{s}'"
130            )));
131        }
132    }
133    Ok(())
134}
135
136fn prevalidate_lambda(action: &str, req: &AwsRequest) -> Result<(), AwsServiceError> {
137    let body: Value = serde_json::from_slice(&req.body).unwrap_or(Value::Null);
138    match action {
139        "PublishVersion" => {
140            check_optional_len("Description", body["Description"].as_str(), 0, 256)?;
141            check_optional_enum(
142                "PublishTo",
143                body["PublishTo"].as_str(),
144                LAMBDA_PUBLISH_TO_VALUES,
145            )?;
146        }
147        "UpdateFunctionCode" => {
148            check_optional_enum(
149                "PublishTo",
150                body["PublishTo"].as_str(),
151                LAMBDA_PUBLISH_TO_VALUES,
152            )?;
153            check_optional_len("S3Bucket", body["S3Bucket"].as_str(), 3, 63)?;
154            check_optional_len("S3Key", body["S3Key"].as_str(), 1, 1024)?;
155            check_optional_len("S3ObjectVersion", body["S3ObjectVersion"].as_str(), 1, 1024)?;
156        }
157        "UpdateFunctionConfiguration" => {
158            check_optional_len("Description", body["Description"].as_str(), 0, 256)?;
159            check_optional_len("Handler", body["Handler"].as_str(), 0, 128)?;
160            check_optional_int_range("MemorySize", body["MemorySize"].as_i64(), 128, 32768)?;
161            check_optional_int_range("Timeout", body["Timeout"].as_i64(), 1, i64::MAX)?;
162            check_optional_enum("Runtime", body["Runtime"].as_str(), LAMBDA_RUNTIMES)?;
163        }
164        _ => {}
165    }
166    Ok(())
167}
168
169/// Lambda actions whose URL `resource_name` slot is a `FunctionName`
170/// (and therefore accepts ARN / partial ARN / `name:qualifier` forms).
171/// Layer / event-source-mapping / code-signing-config actions key off
172/// other resource identifiers and are excluded.
173pub(crate) fn action_takes_function_name(action: &str) -> bool {
174    matches!(
175        action,
176        "GetFunction"
177            | "DeleteFunction"
178            | "Invoke"
179            | "InvokeAsync"
180            | "InvokeWithResponseStream"
181            | "PublishVersion"
182            | "ListVersionsByFunction"
183            | "AddPermission"
184            | "RemovePermission"
185            | "GetPolicy"
186            | "GetFunctionConfiguration"
187            | "UpdateFunctionConfiguration"
188            | "UpdateFunctionCode"
189            | "GetFunctionConcurrency"
190            | "PutFunctionConcurrency"
191            | "DeleteFunctionConcurrency"
192            | "PutProvisionedConcurrencyConfig"
193            | "GetProvisionedConcurrencyConfig"
194            | "DeleteProvisionedConcurrencyConfig"
195            | "ListProvisionedConcurrencyConfigs"
196            | "PutFunctionEventInvokeConfig"
197            | "UpdateFunctionEventInvokeConfig"
198            | "GetFunctionEventInvokeConfig"
199            | "DeleteFunctionEventInvokeConfig"
200            | "ListFunctionEventInvokeConfigs"
201            | "CreateFunctionUrlConfig"
202            | "UpdateFunctionUrlConfig"
203            | "GetFunctionUrlConfig"
204            | "DeleteFunctionUrlConfig"
205            | "ListFunctionUrlConfigs"
206            | "PutFunctionCodeSigningConfig"
207            | "GetFunctionCodeSigningConfig"
208            | "DeleteFunctionCodeSigningConfig"
209            | "GetFunctionScalingConfig"
210            | "PutFunctionScalingConfig"
211            | "PutFunctionRecursionConfig"
212            | "GetFunctionRecursionConfig"
213            | "CreateAlias"
214            | "GetAlias"
215            | "ListAliases"
216            | "UpdateAlias"
217            | "DeleteAlias"
218            | "PutRuntimeManagementConfig"
219            | "GetRuntimeManagementConfig"
220    )
221}
222
223/// Strip an ARN, partial ARN, or trailing `:qualifier` from a Lambda
224/// `FunctionName` input down to the bare function name used as the
225/// state map key. AWS Lambda accepts four forms in URL path slots and
226/// API params:
227///
228///   - `MyFunction`
229///   - `MyFunction:Qualifier`
230///   - `123456789012:function:MyFunction[:Qualifier]`           (partial ARN)
231///   - `arn:aws:lambda:REGION:ACCOUNT:function:MyFunction[:Qualifier]`
232///
233/// Inputs that don't match any of those structures are returned
234/// unchanged. The qualifier (version or alias) is dropped because most
235/// callers look up the function by name and resolve qualifier
236/// separately.
237/// Map a dispatched Lambda operation name (as returned by
238/// [`LambdaService::resolve_action`]) to its AWS IAM action string.
239///
240/// Lambda is `iam_enforceable`, so every op the dispatcher can route MUST
241/// resolve here — an op that returned `None` would run with no policy
242/// evaluation at all (a silent auth bypass). The mapping is driven from
243/// the same op-name strings the dispatcher uses so the two can never
244/// drift; `iam_action_name_for_exhaustiveness` (see tests) asserts every
245/// dispatchable op resolves to `Some`.
246///
247/// Almost all IAM actions equal the operation name. The exceptions come
248/// straight from the Smithy model's `aws.iam#iamAction.name` overrides:
249///   - `Invoke`                     -> `lambda:InvokeFunction`
250///   - `InvokeWithResponseStream`   -> `lambda:InvokeFunction`
251///   - `GetLayerVersionByArn`       -> `lambda:GetLayerVersion`
252pub(crate) fn iam_action_name_for(op: &str) -> Option<&'static str> {
253    let action = match op {
254        // --- Smithy IAM-action name overrides ---
255        "Invoke" => "InvokeFunction",
256        "InvokeWithResponseStream" => "InvokeFunction",
257        "GetLayerVersionByArn" => "GetLayerVersion",
258
259        // --- functions ---
260        "CreateFunction" => "CreateFunction",
261        "ListFunctions" => "ListFunctions",
262        "GetFunction" => "GetFunction",
263        "DeleteFunction" => "DeleteFunction",
264        "InvokeAsync" => "InvokeAsync",
265        "UpdateFunctionCode" => "UpdateFunctionCode",
266        "UpdateFunctionConfiguration" => "UpdateFunctionConfiguration",
267        "GetFunctionConfiguration" => "GetFunctionConfiguration",
268        "PublishVersion" => "PublishVersion",
269        "ListVersionsByFunction" => "ListVersionsByFunction",
270        "GetAccountSettings" => "GetAccountSettings",
271
272        // --- resource policy / permissions ---
273        "AddPermission" => "AddPermission",
274        "RemovePermission" => "RemovePermission",
275        "GetPolicy" => "GetPolicy",
276
277        // --- aliases ---
278        "CreateAlias" => "CreateAlias",
279        "GetAlias" => "GetAlias",
280        "UpdateAlias" => "UpdateAlias",
281        "DeleteAlias" => "DeleteAlias",
282        "ListAliases" => "ListAliases",
283
284        // --- concurrency ---
285        "PutFunctionConcurrency" => "PutFunctionConcurrency",
286        "GetFunctionConcurrency" => "GetFunctionConcurrency",
287        "DeleteFunctionConcurrency" => "DeleteFunctionConcurrency",
288        "PutProvisionedConcurrencyConfig" => "PutProvisionedConcurrencyConfig",
289        "GetProvisionedConcurrencyConfig" => "GetProvisionedConcurrencyConfig",
290        "DeleteProvisionedConcurrencyConfig" => "DeleteProvisionedConcurrencyConfig",
291        "ListProvisionedConcurrencyConfigs" => "ListProvisionedConcurrencyConfigs",
292
293        // --- event invoke config ---
294        "PutFunctionEventInvokeConfig" => "PutFunctionEventInvokeConfig",
295        "GetFunctionEventInvokeConfig" => "GetFunctionEventInvokeConfig",
296        "UpdateFunctionEventInvokeConfig" => "UpdateFunctionEventInvokeConfig",
297        "DeleteFunctionEventInvokeConfig" => "DeleteFunctionEventInvokeConfig",
298        "ListFunctionEventInvokeConfigs" => "ListFunctionEventInvokeConfigs",
299
300        // --- runtime / scaling / recursion config ---
301        "PutRuntimeManagementConfig" => "PutRuntimeManagementConfig",
302        "GetRuntimeManagementConfig" => "GetRuntimeManagementConfig",
303        "PutFunctionScalingConfig" => "PutFunctionScalingConfig",
304        "GetFunctionScalingConfig" => "GetFunctionScalingConfig",
305        "PutFunctionRecursionConfig" => "PutFunctionRecursionConfig",
306        "GetFunctionRecursionConfig" => "GetFunctionRecursionConfig",
307
308        // --- function URL config ---
309        "CreateFunctionUrlConfig" => "CreateFunctionUrlConfig",
310        "GetFunctionUrlConfig" => "GetFunctionUrlConfig",
311        "UpdateFunctionUrlConfig" => "UpdateFunctionUrlConfig",
312        "DeleteFunctionUrlConfig" => "DeleteFunctionUrlConfig",
313        "ListFunctionUrlConfigs" => "ListFunctionUrlConfigs",
314
315        // --- event source mappings ---
316        "CreateEventSourceMapping" => "CreateEventSourceMapping",
317        "ListEventSourceMappings" => "ListEventSourceMappings",
318        "GetEventSourceMapping" => "GetEventSourceMapping",
319        "UpdateEventSourceMapping" => "UpdateEventSourceMapping",
320        "DeleteEventSourceMapping" => "DeleteEventSourceMapping",
321
322        // --- layers ---
323        "PublishLayerVersion" => "PublishLayerVersion",
324        "ListLayers" => "ListLayers",
325        "ListLayerVersions" => "ListLayerVersions",
326        "GetLayerVersion" => "GetLayerVersion",
327        "DeleteLayerVersion" => "DeleteLayerVersion",
328        "GetLayerVersionPolicy" => "GetLayerVersionPolicy",
329        "AddLayerVersionPermission" => "AddLayerVersionPermission",
330        "RemoveLayerVersionPermission" => "RemoveLayerVersionPermission",
331
332        // --- code signing config ---
333        "CreateCodeSigningConfig" => "CreateCodeSigningConfig",
334        "GetCodeSigningConfig" => "GetCodeSigningConfig",
335        "UpdateCodeSigningConfig" => "UpdateCodeSigningConfig",
336        "DeleteCodeSigningConfig" => "DeleteCodeSigningConfig",
337        "ListCodeSigningConfigs" => "ListCodeSigningConfigs",
338        "PutFunctionCodeSigningConfig" => "PutFunctionCodeSigningConfig",
339        "GetFunctionCodeSigningConfig" => "GetFunctionCodeSigningConfig",
340        "DeleteFunctionCodeSigningConfig" => "DeleteFunctionCodeSigningConfig",
341        "ListFunctionsByCodeSigningConfig" => "ListFunctionsByCodeSigningConfig",
342
343        // --- tags ---
344        "TagResource" => "TagResource",
345        "UntagResource" => "UntagResource",
346        "ListTags" => "ListTags",
347
348        // --- capacity providers (Lambda Workflows) ---
349        "CreateCapacityProvider" => "CreateCapacityProvider",
350        "GetCapacityProvider" => "GetCapacityProvider",
351        "ListCapacityProviders" => "ListCapacityProviders",
352        "UpdateCapacityProvider" => "UpdateCapacityProvider",
353        "DeleteCapacityProvider" => "DeleteCapacityProvider",
354        "ListFunctionVersionsByCapacityProvider" => "ListFunctionVersionsByCapacityProvider",
355
356        // --- durable executions ---
357        // The Smithy model carries no `aws.iam#iamAction` annotation for
358        // these preview ops, so the IAM action equals the op name (AWS's
359        // default convention when no override is present).
360        "GetDurableExecution" => "GetDurableExecution",
361        "GetDurableExecutionHistory" => "GetDurableExecutionHistory",
362        "GetDurableExecutionState" => "GetDurableExecutionState",
363        "ListDurableExecutionsByFunction" => "ListDurableExecutionsByFunction",
364        "CheckpointDurableExecution" => "CheckpointDurableExecution",
365        "StopDurableExecution" => "StopDurableExecution",
366        "SendDurableExecutionCallbackSuccess" => "SendDurableExecutionCallbackSuccess",
367        "SendDurableExecutionCallbackFailure" => "SendDurableExecutionCallbackFailure",
368        "SendDurableExecutionCallbackHeartbeat" => "SendDurableExecutionCallbackHeartbeat",
369
370        _ => return None,
371    };
372    Some(action)
373}
374
375pub(crate) fn normalize_function_name(input: &str) -> String {
376    if input.is_empty() {
377        return String::new();
378    }
379
380    // SDKs URL-encode `:` in path segments, so `arn:aws:lambda:...`
381    // arrives as `arn%3Aaws%3Alambda%3A...`. Decode first; legitimate
382    // function names contain no percent-encoded characters, so this is
383    // safe for the bare-name path too.
384    let decoded = percent_encoding::percent_decode_str(input)
385        .decode_utf8_lossy()
386        .into_owned();
387    let input = decoded.as_str();
388
389    // Full ARN: arn:aws:lambda:REGION:ACCOUNT:function:NAME[:QUALIFIER]
390    if let Some(rest) = input.strip_prefix("arn:aws:lambda:") {
391        let parts: Vec<&str> = rest.splitn(5, ':').collect();
392        // parts: [region, account, "function", name, qualifier?]
393        if parts.len() >= 4 && parts[2] == "function" && !parts[3].is_empty() {
394            return parts[3].to_string();
395        }
396        return input.to_string();
397    }
398
399    // Partial ARN: ACCOUNT:function:NAME[:QUALIFIER]
400    let parts: Vec<&str> = input.splitn(4, ':').collect();
401    if parts.len() >= 3 && parts[1] == "function" && parts[0].chars().all(|c| c.is_ascii_digit()) {
402        if !parts[2].is_empty() {
403            return parts[2].to_string();
404        }
405        return input.to_string();
406    }
407
408    // Bare name with qualifier: NAME:QUALIFIER. Only apply when the
409    // input contains exactly one colon and the name part is a valid
410    // Lambda function-name token, so malformed ARNs (e.g. wrong service
411    // or wrong format) fall through unchanged rather than getting their
412    // first colon-segment returned.
413    if input.matches(':').count() == 1 {
414        if let Some((name, _qualifier)) = input.split_once(':') {
415            if !name.is_empty() && name.chars().all(is_function_name_char) {
416                return name.to_string();
417            }
418        }
419    }
420
421    input.to_string()
422}
423
424fn is_function_name_char(c: char) -> bool {
425    c.is_ascii_alphanumeric() || c == '-' || c == '_'
426}
427
428/// Lambda's `Marker`/`MaxItems` list pagination. The items are sorted by their
429/// opaque per-item key (`marker_of`) for a stable order, then sliced: a
430/// non-empty `Marker` resumes after the item whose key equals it, and
431/// `MaxItems` bounds the page. Returns `(page, next_marker)` where the marker
432/// is the last returned item's key when more remain, else `""` (AWS emits an
433/// empty-string `NextMarker` on the final page rather than omitting it).
434///
435/// An unrecognised marker (its item was deleted between calls) yields an empty
436/// final page, matching AWS's "resume past the end" behaviour.
437pub(crate) fn paginate_marker<T, F>(
438    mut items: Vec<T>,
439    marker: Option<&str>,
440    max_items: Option<usize>,
441    marker_of: F,
442) -> (Vec<T>, String)
443where
444    F: Fn(&T) -> String,
445{
446    items.sort_by_key(&marker_of);
447
448    let start = match marker {
449        Some(m) if !m.is_empty() => items
450            .iter()
451            .position(|it| marker_of(it) == m)
452            .map(|p| p + 1)
453            .unwrap_or(items.len()),
454        _ => 0,
455    };
456    if start >= items.len() {
457        return (Vec::new(), String::new());
458    }
459
460    let limit = max_items.filter(|&n| n > 0).unwrap_or(usize::MAX);
461    let end = start.saturating_add(limit).min(items.len());
462    let next_marker = if end < items.len() {
463        marker_of(&items[end - 1])
464    } else {
465        String::new()
466    };
467    let page: Vec<T> = items.drain(start..end).collect();
468    (page, next_marker)
469}
470
471/// Parse `MaxItems` from the query (already range-validated upstream) into a
472/// page size, returning `None` when absent.
473pub(crate) fn marker_page_size(req: &AwsRequest) -> Option<usize> {
474    req.query_params
475        .get("MaxItems")
476        .and_then(|s| s.parse::<usize>().ok())
477}
478
479/// Extract a version/alias qualifier embedded in a function reference, the
480/// mirror of [`normalize_function_name`] (which strips it). AWS accepts the
481/// qualifier inline -- `...:function:MyFn:PROD`, `123:function:MyFn:PROD`,
482/// `MyFn:PROD` -- and Invoke must honor it when no `?Qualifier=` is supplied;
483/// previously it was dropped and the invoke silently ran `$LATEST`
484/// (bug-audit 2026-06-20, 1.3). Returns `None` when no qualifier is present.
485pub(crate) fn qualifier_from_function_ref(input: &str) -> Option<String> {
486    if input.is_empty() {
487        return None;
488    }
489    let decoded = percent_encoding::percent_decode_str(input)
490        .decode_utf8_lossy()
491        .into_owned();
492    let input = decoded.as_str();
493
494    if let Some(rest) = input.strip_prefix("arn:aws:lambda:") {
495        // [region, account, "function", name, qualifier?]
496        let parts: Vec<&str> = rest.splitn(5, ':').collect();
497        if parts.len() == 5 && parts[2] == "function" && !parts[4].is_empty() {
498            return Some(parts[4].to_string());
499        }
500        return None;
501    }
502    // Partial ARN: ACCOUNT:function:NAME[:QUALIFIER]
503    let parts: Vec<&str> = input.splitn(4, ':').collect();
504    if parts.len() == 4
505        && parts[1] == "function"
506        && parts[0].chars().all(|c| c.is_ascii_digit())
507        && !parts[3].is_empty()
508    {
509        return Some(parts[3].to_string());
510    }
511    // Bare name with qualifier: NAME:QUALIFIER (exactly one colon, valid name).
512    if input.matches(':').count() == 1 {
513        if let Some((name, qualifier)) = input.split_once(':') {
514            if !name.is_empty() && name.chars().all(is_function_name_char) && !qualifier.is_empty()
515            {
516                return Some(qualifier.to_string());
517            }
518        }
519    }
520    None
521}
522
523/// AWS bounds `EphemeralStorage.Size` to `[512, 10240]` MiB. Anything
524/// outside that range is rejected at the API edge with
525/// `InvalidParameterValueException`, matching the real Lambda control
526/// plane. Returns the validated size unchanged on success.
527pub(crate) fn validate_ephemeral_storage(size: i64) -> Result<i64, AwsServiceError> {
528    if !(512..=10240).contains(&size) {
529        return Err(AwsServiceError::aws_error(
530            StatusCode::BAD_REQUEST,
531            "InvalidParameterValueException",
532            format!(
533                "Value {size} at 'ephemeralStorage.size' failed to satisfy constraint: \
534                 Member must satisfy constraint: [Member must have value less than or equal to 10240, \
535                 Member must have value greater than or equal to 512]"
536            ),
537        ));
538    }
539    Ok(size)
540}
541
542/// All fields of a `CreateFunction` request, already parsed and
543/// defaulted. The code zip (if any) is eagerly base64-decoded so the
544/// caller can hash it without doing the decode again.
545struct CreateFunctionInput {
546    function_name: String,
547    runtime: String,
548    role: String,
549    handler: String,
550    description: String,
551    timeout: i64,
552    memory_size: i64,
553    package_type: String,
554    tags: BTreeMap<String, String>,
555    environment: BTreeMap<String, String>,
556    architectures: Vec<String>,
557    code_zip: Option<Vec<u8>>,
558    code_fallback: Vec<u8>,
559    image_uri: Option<String>,
560    layer_arns: Vec<String>,
561    tracing_mode: Option<String>,
562    kms_key_arn: Option<String>,
563    ephemeral_storage_size: Option<i64>,
564    vpc_config: Option<serde_json::Value>,
565    snap_start: Option<serde_json::Value>,
566    dead_letter_config_arn: Option<String>,
567    file_system_configs: Vec<serde_json::Value>,
568    logging_config: Option<serde_json::Value>,
569    image_config: Option<serde_json::Value>,
570    durable_config: Option<serde_json::Value>,
571}
572
573impl CreateFunctionInput {
574    fn from_body(body: &Value) -> Result<Self, AwsServiceError> {
575        let function_name = body["FunctionName"]
576            .as_str()
577            .ok_or_else(|| {
578                AwsServiceError::aws_error(
579                    StatusCode::BAD_REQUEST,
580                    "InvalidParameterValueException",
581                    "FunctionName is required",
582                )
583            })?
584            .to_string();
585
586        let tags: BTreeMap<String, String> = body["Tags"]
587            .as_object()
588            .map(|m| {
589                m.iter()
590                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
591                    .collect()
592            })
593            .unwrap_or_default();
594
595        let environment: BTreeMap<String, String> = body["Environment"]["Variables"]
596            .as_object()
597            .map(|m| {
598                m.iter()
599                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
600                    .collect()
601            })
602            .unwrap_or_default();
603
604        let architectures = body["Architectures"]
605            .as_array()
606            .map(|a| {
607                a.iter()
608                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
609                    .collect()
610            })
611            .unwrap_or_else(|| vec!["x86_64".to_string()]);
612
613        let code_zip: Option<Vec<u8>> = match body["Code"]["ZipFile"].as_str() {
614            Some(b64) => Some(
615                base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(
616                    |_| {
617                        AwsServiceError::aws_error(
618                            StatusCode::BAD_REQUEST,
619                            "InvalidParameterValueException",
620                            "Could not decode Code.ZipFile: invalid base64",
621                        )
622                    },
623                )?,
624            ),
625            None => None,
626        };
627
628        let code_fallback = serde_json::to_vec(&body["Code"]).unwrap_or_default();
629
630        let package_type = body["PackageType"].as_str().unwrap_or("Zip").to_string();
631        // ImageUri belongs to `PackageType=Image` functions. Silently
632        // dropping it on `Zip` functions avoids GetFunction returning
633        // ECR code metadata for a Zip-based function (AWS ignores the
634        // field entirely in that case too).
635        let image_uri = if package_type == "Image" {
636            body["Code"]["ImageUri"].as_str().map(String::from)
637        } else {
638            None
639        };
640
641        // PackageType=Image requires Code.ImageUri; PackageType=Zip requires
642        // code content. Reject inconsistent shapes with AWS's error code so
643        // SDK-level validation tests see matching behaviour.
644        if package_type == "Image" && image_uri.is_none() {
645            return Err(AwsServiceError::aws_error(
646                StatusCode::BAD_REQUEST,
647                "InvalidParameterValueException",
648                "Code.ImageUri is required when PackageType is Image",
649            ));
650        }
651
652        let layer_arns: Vec<String> = body["Layers"]
653            .as_array()
654            .map(|arr| {
655                arr.iter()
656                    .filter_map(|v| v.as_str().map(String::from))
657                    .collect()
658            })
659            .unwrap_or_default();
660
661        let tracing_mode = body["TracingConfig"]["Mode"].as_str().map(String::from);
662        let kms_key_arn = body["KMSKeyArn"].as_str().map(String::from);
663        let ephemeral_storage_size = match body["EphemeralStorage"]["Size"].as_i64() {
664            Some(size) => Some(validate_ephemeral_storage(size)?),
665            None => None,
666        };
667        let vpc_config = body["VpcConfig"]
668            .is_object()
669            .then(|| body["VpcConfig"].clone());
670        let snap_start = body["SnapStart"]
671            .is_object()
672            .then(|| body["SnapStart"].clone());
673        let dead_letter_config_arn = body["DeadLetterConfig"]["TargetArn"]
674            .as_str()
675            .map(String::from);
676        let file_system_configs = body["FileSystemConfigs"]
677            .as_array()
678            .cloned()
679            .unwrap_or_default();
680        let logging_config = body["LoggingConfig"]
681            .is_object()
682            .then(|| body["LoggingConfig"].clone());
683        let image_config = body["ImageConfig"]
684            .is_object()
685            .then(|| body["ImageConfig"].clone());
686        let durable_config = body["DurableConfig"]
687            .is_object()
688            .then(|| body["DurableConfig"].clone());
689
690        Ok(Self {
691            function_name,
692            runtime: body["Runtime"].as_str().unwrap_or("python3.12").to_string(),
693            role: body["Role"].as_str().unwrap_or("").to_string(),
694            handler: body["Handler"]
695                .as_str()
696                .unwrap_or("index.handler")
697                .to_string(),
698            description: body["Description"].as_str().unwrap_or("").to_string(),
699            timeout: body["Timeout"].as_i64().unwrap_or(3),
700            memory_size: body["MemorySize"].as_i64().unwrap_or(128),
701            package_type,
702            tags,
703            environment,
704            architectures,
705            code_zip,
706            code_fallback,
707            image_uri,
708            layer_arns,
709            tracing_mode,
710            kms_key_arn,
711            ephemeral_storage_size,
712            vpc_config,
713            snap_start,
714            dead_letter_config_arn,
715            file_system_configs,
716            logging_config,
717            image_config,
718            durable_config,
719        })
720    }
721}
722
723/// AWS Lambda's InvocationType: synchronous, async (event), or dry-run.
724#[derive(Debug, Clone, Copy, PartialEq, Eq)]
725pub enum InvocationType {
726    RequestResponse,
727    Event,
728    DryRun,
729}
730
731impl InvocationType {
732    pub fn from_header(value: Option<&str>) -> Self {
733        match value {
734            Some("Event") => Self::Event,
735            Some("DryRun") => Self::DryRun,
736            _ => Self::RequestResponse,
737        }
738    }
739}
740
741/// Route an async-invoke result to the configured OnSuccess / OnFailure
742/// destination. Destination is matched by ARN scheme: SQS, SNS, EventBridge,
743/// or another Lambda. Mirrors the AWS Lambda destinations record schema.
744fn route_to_destination(
745    bus: Arc<fakecloud_core::delivery::DeliveryBus>,
746    function_arn: &str,
747    request_payload: &[u8],
748    result: &Result<Vec<u8>, String>,
749    destination_config: Option<&serde_json::Value>,
750) {
751    let Some(cfg) = destination_config else {
752        return;
753    };
754    let (key, condition, response_value): (&str, &str, serde_json::Value) = match result {
755        Ok(bytes) => (
756            "OnSuccess",
757            "Success",
758            serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null),
759        ),
760        Err(err) => (
761            "OnFailure",
762            "RetriesExhausted",
763            serde_json::json!({ "errorMessage": err }),
764        ),
765    };
766    let Some(dest) = cfg
767        .get(key)
768        .and_then(|v| v.get("Destination"))
769        .and_then(|v| v.as_str())
770    else {
771        return;
772    };
773    let request_payload_v: serde_json::Value =
774        serde_json::from_slice(request_payload).unwrap_or(serde_json::Value::Null);
775    let record = serde_json::json!({
776        "version": "1.0",
777        "timestamp": chrono::Utc::now().to_rfc3339(),
778        "requestContext": {
779            "requestId": uuid::Uuid::new_v4().to_string(),
780            "functionArn": format!("{function_arn}:$LATEST"),
781            "condition": condition,
782            "approximateInvokeCount": 1,
783        },
784        "requestPayload": request_payload_v,
785        "responseContext": {
786            "statusCode": 200,
787            "executedVersion": "$LATEST",
788        },
789        "responsePayload": response_value,
790    });
791    let body = record.to_string();
792    if dest.contains(":sqs:") {
793        bus.send_to_sqs(dest, &body, &std::collections::HashMap::new());
794    } else if dest.contains(":sns:") {
795        bus.publish_to_sns(dest, &body, None);
796    } else if dest.contains(":lambda:") {
797        let dest = dest.to_string();
798        let payload = body.clone();
799        tokio::spawn(async move {
800            let _ = bus.invoke_lambda(&dest, &payload).await;
801        });
802    } else if dest.contains(":events:") || dest.contains(":eventbridge:") {
803        let detail_type = if result.is_ok() {
804            "Lambda Function Invocation Result - Success"
805        } else {
806            "Lambda Function Invocation Result - Failure"
807        };
808        bus.put_event_to_eventbridge("lambda", detail_type, &body, "default");
809    }
810}
811
812/// Decrements the per-function in-flight counter on drop. Lives as
813/// long as the invocation it gates — for synchronous invokes that's
814/// the function call's stack frame; for `Event` invokes the guard is
815/// moved into the spawned task so the counter drops only when the
816/// async work finishes.
817pub(crate) struct ConcurrencyGuard {
818    pub(crate) map: Arc<parking_lot::RwLock<BTreeMap<String, i64>>>,
819    pub(crate) key: String,
820}
821
822impl Drop for ConcurrencyGuard {
823    fn drop(&mut self) {
824        let mut m = self.map.write();
825        let n = m.get(&self.key).copied().unwrap_or(0);
826        if n <= 1 {
827            m.remove(&self.key);
828        } else {
829            m.insert(self.key.clone(), n - 1);
830        }
831    }
832}
833
834/// Map an Invoke `Qualifier` (alias name, numeric version, or
835/// `$LATEST`) to a concrete numeric version string. Aliases with a
836/// `RoutingConfig.AdditionalVersionWeights` table do a weighted pick
837/// across the alias's primary `function_version` plus the additional
838/// True when `prev` is byte-equivalent to `live` for every field
839/// that `PublishVersion` would otherwise capture into a new snapshot.
840/// Used to short-circuit a no-op publish (AWS-style idempotency:
841/// re-publishing without any change returns the previous version
842/// unchanged). The comparison spans code identity (sha + size),
843/// configuration (runtime/handler/role/timeout/memory/env/layers/...)
844/// and every advanced field round-tripped through
845/// `function_config_json`. The caller is responsible for resolving
846/// the `effective_description` (caller-supplied override wins over
847/// the live `$LATEST` description, matching real PublishVersion
848/// semantics).
849fn function_config_unchanged_for_publish(
850    prev: &LambdaFunction,
851    live: &LambdaFunction,
852    effective_description: &str,
853) -> bool {
854    prev.code_sha256 == live.code_sha256
855        && prev.code_size == live.code_size
856        && prev.image_uri == live.image_uri
857        && prev.package_type == live.package_type
858        && prev.runtime == live.runtime
859        && prev.role == live.role
860        && prev.handler == live.handler
861        && prev.description == effective_description
862        && prev.timeout == live.timeout
863        && prev.memory_size == live.memory_size
864        && prev.environment == live.environment
865        && prev.architectures == live.architectures
866        && prev.layers.len() == live.layers.len()
867        && prev
868            .layers
869            .iter()
870            .zip(live.layers.iter())
871            .all(|(a, b)| a.arn == b.arn && a.code_size == b.code_size)
872        && prev.tracing_mode == live.tracing_mode
873        && prev.kms_key_arn == live.kms_key_arn
874        && prev.ephemeral_storage_size == live.ephemeral_storage_size
875        && prev.vpc_config == live.vpc_config
876        && prev.dead_letter_config_arn == live.dead_letter_config_arn
877        && prev.file_system_configs == live.file_system_configs
878        && prev.logging_config == live.logging_config
879        && prev.image_config == live.image_config
880        && prev.signing_profile_version_arn == live.signing_profile_version_arn
881        && prev.signing_job_arn == live.signing_job_arn
882        && prev.runtime_version_config == live.runtime_version_config
883        && snap_start_apply_on_eq(prev.snap_start.as_ref(), live.snap_start.as_ref())
884}
885
886/// Compare two `SnapStart` configs by `ApplyOn` only — that's the
887/// caller-supplied knob. `OptimizationStatus` is server-side state
888/// that PublishVersion mutates on snapshots (flipping to "On" when
889/// ApplyOn=PublishedVersions) while $LATEST stays "Off", so a deep
890/// equality check here would never match on a SnapStart-enabled
891/// function and PublishVersion would never be idempotent. Treating
892/// `None` and `{ApplyOn:"None"}` as equivalent matches AWS, which
893/// emits the latter when the field is unset.
894fn snap_start_apply_on_eq(prev: Option<&Value>, live: Option<&Value>) -> bool {
895    let prev_apply = prev
896        .and_then(|v| v.get("ApplyOn"))
897        .and_then(|v| v.as_str())
898        .unwrap_or("None");
899    let live_apply = live
900        .and_then(|v| v.get("ApplyOn"))
901        .and_then(|v| v.as_str())
902        .unwrap_or("None");
903    prev_apply == live_apply
904}
905
906/// versions in the weight map. Returns `None` for `$LATEST` /
907/// unqualified invokes (caller uses the live `$LATEST` config).
908pub(crate) fn resolve_qualifier_to_version(
909    state: &LambdaState,
910    function_name: &str,
911    qualifier: Option<&str>,
912) -> Option<String> {
913    let q = qualifier?;
914    if q == "$LATEST" {
915        return None;
916    }
917    if q.chars().all(|c| c.is_ascii_digit()) {
918        return Some(q.to_string());
919    }
920    let alias_key = format!("{function_name}:{q}");
921    let alias = state.aliases.get(&alias_key)?;
922    let primary = alias.function_version.clone();
923    let routing = alias
924        .routing_config
925        .as_ref()
926        .and_then(|rc| rc.get("AdditionalVersionWeights"))
927        .and_then(|m| m.as_object());
928    let Some(weights) = routing else {
929        return Some(primary);
930    };
931    // Sum of additional weights ∈ [0,1]; primary gets 1 - sum. Pick
932    // uniformly in [0,1) and walk the cumulative weight axis.
933    let mut additional: Vec<(String, f64)> = Vec::with_capacity(weights.len());
934    let mut sum: f64 = 0.0;
935    for (ver, w) in weights {
936        let weight = w.as_f64().unwrap_or(0.0).clamp(0.0, 1.0);
937        sum += weight;
938        additional.push((ver.clone(), weight));
939    }
940    let primary_weight = (1.0 - sum).max(0.0);
941    let pick: f64 = {
942        // Mix a thread-local LCG state with wall-clock nanos so
943        // back-to-back calls within a single process tick still
944        // produce distinct picks. Invoke routing only needs fairness
945        // over many invokes, not crypto randomness.
946        use std::cell::Cell;
947        thread_local! {
948            static RNG: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
949        }
950        let now_nanos = std::time::SystemTime::now()
951            .duration_since(std::time::UNIX_EPOCH)
952            .map(|d| d.as_nanos() as u64)
953            .unwrap_or(0);
954        RNG.with(|cell| {
955            let mut s = cell.get() ^ now_nanos;
956            // splitmix64 step
957            s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
958            let mut z = s;
959            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
960            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
961            z ^= z >> 31;
962            cell.set(s);
963            (z >> 11) as f64 / ((1u64 << 53) as f64)
964        })
965    };
966    let mut acc = primary_weight;
967    if pick < acc {
968        return Some(primary);
969    }
970    for (ver, w) in &additional {
971        acc += w;
972        if pick < acc {
973            return Some(ver.clone());
974        }
975    }
976    Some(primary)
977}
978
979pub struct LambdaService {
980    pub(crate) state: SharedLambdaState,
981    pub(crate) runtime: Option<Arc<ContainerRuntime>>,
982    snapshot_store: Option<Arc<dyn SnapshotStore>>,
983    snapshot_lock: Arc<AsyncMutex<()>>,
984    pub(crate) delivery_bus: Option<Arc<fakecloud_core::delivery::DeliveryBus>>,
985    pub(crate) role_trust_validator: Option<Arc<dyn fakecloud_core::auth::RoleTrustValidator>>,
986    pub(crate) s3_delivery: Option<Arc<dyn fakecloud_core::delivery::S3Delivery>>,
987    /// Per-account-per-function in-flight invocation count, used to
988    /// gate `Invoke` against `PutFunctionConcurrency`'s
989    /// `ReservedConcurrentExecutions` ceiling. Keyed by
990    /// `{account_id}:{function_name}`. Live counter — incremented at
991    /// invoke entry, decremented when the invocation completes (or
992    /// when the spawned async task finishes for `Event` invokes).
993    pub(crate) inflight_invocations: Arc<parking_lot::RwLock<BTreeMap<String, i64>>>,
994}
995
996mod functions;
997mod init;
998mod invoke;
999mod publish;
1000
1001impl LambdaService {
1002    pub fn new(state: SharedLambdaState) -> Self {
1003        Self {
1004            state,
1005            runtime: None,
1006            snapshot_store: None,
1007            snapshot_lock: Arc::new(AsyncMutex::new(())),
1008            delivery_bus: None,
1009            role_trust_validator: None,
1010            s3_delivery: None,
1011            inflight_invocations: Arc::new(parking_lot::RwLock::new(BTreeMap::new())),
1012        }
1013    }
1014
1015    pub fn with_s3_delivery(mut self, s3: Arc<dyn fakecloud_core::delivery::S3Delivery>) -> Self {
1016        self.s3_delivery = Some(s3);
1017        self
1018    }
1019
1020    pub fn with_runtime(mut self, runtime: Arc<ContainerRuntime>) -> Self {
1021        self.runtime = Some(runtime);
1022        self
1023    }
1024
1025    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
1026        self.snapshot_store = Some(store);
1027        self
1028    }
1029
1030    pub fn with_delivery_bus(mut self, bus: Arc<fakecloud_core::delivery::DeliveryBus>) -> Self {
1031        self.delivery_bus = Some(bus);
1032        self
1033    }
1034
1035    pub fn with_role_trust_validator(
1036        mut self,
1037        validator: Arc<dyn fakecloud_core::auth::RoleTrustValidator>,
1038    ) -> Self {
1039        self.role_trust_validator = Some(validator);
1040        self
1041    }
1042
1043    async fn save_snapshot(&self) {
1044        save_lambda_snapshot(
1045            &self.state,
1046            self.snapshot_store.clone(),
1047            &self.snapshot_lock,
1048        )
1049        .await;
1050    }
1051
1052    /// Build a hook that persists the current Lambda state when invoked, or
1053    /// `None` in memory mode (no snapshot store). The CloudFormation provisioner
1054    /// mutates `state` directly and uses this to write a CFN-provisioned
1055    /// function through to disk, the same way a direct mutating API call would.
1056    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
1057        let store = self.snapshot_store.clone()?;
1058        let state = self.state.clone();
1059        let lock = self.snapshot_lock.clone();
1060        Some(Arc::new(move || {
1061            let state = state.clone();
1062            let store = store.clone();
1063            let lock = lock.clone();
1064            Box::pin(async move {
1065                save_lambda_snapshot(&state, Some(store), &lock).await;
1066            })
1067        }))
1068    }
1069}
1070
1071/// Persist the current Lambda state as a snapshot. Offloads the serde +
1072/// blocking file write to the Tokio blocking pool. Noop when `store` is `None`
1073/// (memory mode). Shared by `LambdaService::save_snapshot` and the
1074/// CloudFormation provisioner's post-provision persist hook so both route
1075/// through the same serialize-and-write path.
1076pub async fn save_lambda_snapshot(
1077    state: &SharedLambdaState,
1078    store: Option<Arc<dyn SnapshotStore>>,
1079    lock: &AsyncMutex<()>,
1080) {
1081    let Some(store) = store else {
1082        return;
1083    };
1084    let _guard = lock.lock().await;
1085    let snapshot = LambdaSnapshot {
1086        schema_version: LAMBDA_SNAPSHOT_SCHEMA_VERSION,
1087        accounts: Some(state.read().clone()),
1088        state: None,
1089    };
1090    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
1091        let bytes = serde_json::to_vec(&snapshot)
1092            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
1093        store.save(&bytes)
1094    })
1095    .await;
1096    match join {
1097        Ok(Ok(())) => {}
1098        Ok(Err(err)) => tracing::error!(%err, "failed to write lambda snapshot"),
1099        Err(err) => tracing::error!(%err, "lambda snapshot task panicked"),
1100    }
1101}
1102
1103#[async_trait]
1104impl AwsService for LambdaService {
1105    fn service_name(&self) -> &str {
1106        "lambda"
1107    }
1108
1109    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1110        let (action, resource_name) = Self::resolve_action(&req).ok_or_else(|| {
1111            // Distinguish a genuinely unknown URL path from one that
1112            // hit a known Lambda collection (`/functions`, `/layers`,
1113            // `/event-source-mappings`, `/tags`, `/code-signing-configs`,
1114            // `/account-settings`, `/layers-by-arn`) but couldn't be
1115            // routed because a required identifier was empty or the
1116            // method was wrong. The latter is a client-side validation
1117            // error (`InvalidParameterValueException`), not a
1118            // "service doesn't implement this" signal — collapsing the
1119            // two confuses conformance probes whose synthetic too-short
1120            // identifiers collapse path segments at the URL level.
1121            const KNOWN_COLLECTIONS: &[&str] = &[
1122                "functions",
1123                "layers",
1124                "layers-by-arn",
1125                "event-source-mappings",
1126                "tags",
1127                "account-settings",
1128                "code-signing-configs",
1129            ];
1130            let is_known_collection = req
1131                .path_segments
1132                .get(1)
1133                .map(|s| KNOWN_COLLECTIONS.contains(&s.as_str()))
1134                .unwrap_or(false);
1135            if is_known_collection {
1136                AwsServiceError::aws_error(
1137                    StatusCode::BAD_REQUEST,
1138                    "InvalidParameterValueException",
1139                    format!(
1140                        "Could not route request {} {} — missing or invalid identifier",
1141                        req.method, req.raw_path
1142                    ),
1143                )
1144            } else {
1145                AwsServiceError::aws_error(
1146                    StatusCode::NOT_FOUND,
1147                    "UnknownOperationException",
1148                    format!("Unknown operation: {} {}", req.method, req.raw_path),
1149                )
1150            }
1151        })?;
1152
1153        // Normalize FunctionName-bearing resource slots: AWS Lambda accepts
1154        // bare name, name:qualifier, partial ARN, and full ARN in any URL
1155        // slot that names a function. Layer / event-source-mapping resource
1156        // names go through different routes and are left as-is.
1157        // Capture a qualifier embedded in the raw function reference (e.g.
1158        // `...:function:MyFn:PROD`) before normalization strips it, so Invoke
1159        // can fall back to it when no `?Qualifier=` is supplied (1.3).
1160        let arn_embedded_qualifier = resource_name
1161            .as_deref()
1162            .and_then(qualifier_from_function_ref);
1163        let resource_name = if action_takes_function_name(action) {
1164            // Enforce the Smithy length bound (`FunctionName.length 1..140`)
1165            // before normalization. Synthetic conformance variants drive
1166            // 141-character strings through these paths; without an early
1167            // reject we'd happily serve `GetFunction` against a name that
1168            // could never have been created. The 170-char ceiling tracks
1169            // the documented ARN-form upper bound.
1170            if let Some(raw) = resource_name.as_ref() {
1171                // Percent-decode the path label before length-checking;
1172                // SDK clients escape `:` to `%3A` for ARN-form names, so
1173                // the raw count overruns the 200-char ARN ceiling on
1174                // valid inputs.
1175                let decoded = crate::extras::percent_decode_for_length(raw);
1176                let len = decoded.chars().count();
1177                // Bare-name form caps at 140. ARN form
1178                // (`arn:aws:lambda:<region>:<acct>:function:<name>`)
1179                // adds ~60 chars of prefix → up to ~200 total. Reject
1180                // anything longer outright so synthetic 141-char names
1181                // can't bypass the constraint. `InvokeAsync`'s Smithy
1182                // error envelope doesn't declare
1183                // `InvalidParameterValueException`, so route its
1184                // too-long inputs through `ResourceNotFoundException`
1185                // instead — which is declared, and also reflects
1186                // the practical outcome of looking up a 141-char name.
1187                let limit = if decoded.starts_with("arn:") {
1188                    200
1189                } else {
1190                    140
1191                };
1192                if decoded.is_empty() || len > limit {
1193                    let (code, msg) = if action == "InvokeAsync" {
1194                        (
1195                            "ResourceNotFoundException",
1196                            format!("Function not found: {}", raw),
1197                        )
1198                    } else {
1199                        (
1200                            "InvalidParameterValueException",
1201                            format!(
1202                                "1 validation error detected: Value '{}' at 'functionName' failed to \
1203                                 satisfy constraint: Member must have length less than or equal to 140",
1204                                raw
1205                            ),
1206                        )
1207                    };
1208                    return Err(AwsServiceError::aws_error(
1209                        if action == "InvokeAsync" {
1210                            StatusCode::NOT_FOUND
1211                        } else {
1212                            StatusCode::BAD_REQUEST
1213                        },
1214                        code,
1215                        msg,
1216                    ));
1217                }
1218            }
1219            resource_name.map(|s| normalize_function_name(&s))
1220        } else {
1221            resource_name
1222        };
1223
1224        // Generic MaxItems range guard. The query is bound to different
1225        // Smithy integer shapes per operation (general `MaxListItems`
1226        // is 1..10000; layer/url/event-invoke/provisioned-concurrency
1227        // listings cap at 50). Pick the right ceiling for the routed
1228        // action so above-max variants trip the validation reliably.
1229        if let Some(raw) = req.query_params.get("MaxItems") {
1230            // Non-numeric MaxItems is a malformed request, not "use the
1231            // default". AWS responds 400 — reject before falling through
1232            // to range-check on the parsed value.
1233            let n = raw.parse::<i64>().map_err(|_| {
1234                AwsServiceError::aws_error(
1235                    StatusCode::BAD_REQUEST,
1236                    "InvalidParameterValueException",
1237                    format!("MaxItems must be a number (got '{raw}')"),
1238                )
1239            })?;
1240            let max = match action {
1241                "ListLayers"
1242                | "ListLayerVersions"
1243                | "ListFunctionUrlConfigs"
1244                | "ListProvisionedConcurrencyConfigs"
1245                | "ListFunctionEventInvokeConfigs"
1246                | "ListAliases" => 50,
1247                _ => 10000,
1248            };
1249            if !(1..=max).contains(&n) {
1250                return Err(AwsServiceError::aws_error(
1251                    StatusCode::BAD_REQUEST,
1252                    "InvalidParameterValueException",
1253                    format!("MaxItems must be between 1 and {} (got {})", max, n),
1254                ));
1255            }
1256        }
1257
1258        // Smithy `Qualifier` shape is `length 1..128`. Probe variants
1259        // exercise the lower boundary by sending the empty string;
1260        // reject pre-dispatch so every per-handler `parse_qualifier`
1261        // call doesn't need its own check.
1262        if let Some(q) = req.query_params.get("Qualifier") {
1263            let len = q.chars().count();
1264            if q.is_empty() || len > 128 {
1265                return Err(AwsServiceError::aws_error(
1266                    StatusCode::BAD_REQUEST,
1267                    "InvalidParameterValueException",
1268                    format!("Qualifier must be 1..128 characters (got length {})", len),
1269                ));
1270            }
1271        }
1272        // Same guard for the `FunctionVersion` query member used by
1273        // `ListAliases` (`length 1..1024` / pattern `(\\$LATEST|[0-9]+)`).
1274        if let Some(fv) = req.query_params.get("FunctionVersion") {
1275            let len = fv.chars().count();
1276            if fv.is_empty() || len > 1024 {
1277                return Err(AwsServiceError::aws_error(
1278                    StatusCode::BAD_REQUEST,
1279                    "InvalidParameterValueException",
1280                    format!(
1281                        "FunctionVersion must be 1..1024 characters (got length {})",
1282                        len
1283                    ),
1284                ));
1285            }
1286        }
1287
1288        let mutates = matches!(
1289            action,
1290            "CreateFunction"
1291                | "DeleteFunction"
1292                | "PublishVersion"
1293                | "AddPermission"
1294                | "RemovePermission"
1295                | "CreateEventSourceMapping"
1296                | "DeleteEventSourceMapping"
1297                | "UpdateEventSourceMapping"
1298                | "UpdateFunctionCode"
1299                | "UpdateFunctionConfiguration"
1300                | "CreateAlias"
1301                | "DeleteAlias"
1302                | "UpdateAlias"
1303                | "PublishLayerVersion"
1304                | "DeleteLayerVersion"
1305                | "AddLayerVersionPermission"
1306                | "RemoveLayerVersionPermission"
1307                | "CreateFunctionUrlConfig"
1308                | "DeleteFunctionUrlConfig"
1309                | "UpdateFunctionUrlConfig"
1310                | "PutFunctionConcurrency"
1311                | "DeleteFunctionConcurrency"
1312                | "PutProvisionedConcurrencyConfig"
1313                | "DeleteProvisionedConcurrencyConfig"
1314                | "CreateCodeSigningConfig"
1315                | "UpdateCodeSigningConfig"
1316                | "DeleteCodeSigningConfig"
1317                | "PutFunctionCodeSigningConfig"
1318                | "DeleteFunctionCodeSigningConfig"
1319                | "PutFunctionEventInvokeConfig"
1320                | "UpdateFunctionEventInvokeConfig"
1321                | "DeleteFunctionEventInvokeConfig"
1322                | "PutRuntimeManagementConfig"
1323                | "PutFunctionScalingConfig"
1324                | "PutFunctionRecursionConfig"
1325                | "TagResource"
1326                | "UntagResource"
1327                | "InvokeAsync"
1328                | "InvokeWithResponseStream"
1329        );
1330
1331        let aid = &req.account_id;
1332        // Smithy-aligned validation for the handful of input fields whose
1333        // refreshed @length / @range / enum constraints surface as new
1334        // conformance variants. Centralised here so the body parser in each
1335        // handler stays focused on shape transforms.
1336        prevalidate_lambda(action, &req)?;
1337        let result = match action {
1338            "CreateFunction" => self.create_function(&req),
1339            "ListFunctions" => self.list_functions(
1340                aid,
1341                req.query_params.get("FunctionVersion").map(String::as_str),
1342                req.query_params.get("Marker").map(String::as_str),
1343                marker_page_size(&req),
1344            ),
1345            "GetFunction" => self.get_function(
1346                &req,
1347                resource_name.as_deref().unwrap_or(""),
1348                aid,
1349                req.region.as_str(),
1350                req.query_params.get("Qualifier").map(String::as_str),
1351            ),
1352            "DeleteFunction" => self.delete_function(
1353                resource_name.as_deref().unwrap_or(""),
1354                aid,
1355                req.query_params.get("Qualifier").map(String::as_str),
1356            ),
1357            "Invoke" => {
1358                let invocation_type = InvocationType::from_header(
1359                    req.headers
1360                        .get("x-amz-invocation-type")
1361                        .and_then(|v| v.to_str().ok()),
1362                );
1363                // `?Qualifier=` wins; otherwise honor a qualifier embedded in
1364                // the function ARN/ref (1.3).
1365                let qualifier = req
1366                    .query_params
1367                    .get("Qualifier")
1368                    .map(String::as_str)
1369                    .or(arn_embedded_qualifier.as_deref());
1370                self.invoke(
1371                    resource_name.as_deref().unwrap_or(""),
1372                    &req.body,
1373                    aid,
1374                    invocation_type,
1375                    qualifier,
1376                )
1377                .await
1378            }
1379            "InvokeAsync" => {
1380                // `InvokeAsync` is deprecated. AWS returns 202 with a
1381                // `Status` body and never surfaces synchronous-invoke
1382                // errors (`InvalidParameterValueException` isn't in
1383                // the op's declared error envelope). Validate the
1384                // function exists, then enqueue is a no-op.
1385                let name = resource_name.as_deref().unwrap_or("");
1386                let accounts = self.state.read();
1387                let exists = accounts
1388                    .get(aid)
1389                    .map(|s| s.functions.contains_key(name))
1390                    .unwrap_or(false);
1391                if !exists {
1392                    Err(AwsServiceError::aws_error(
1393                        StatusCode::NOT_FOUND,
1394                        "ResourceNotFoundException",
1395                        format!("Function not found: {}", name),
1396                    ))
1397                } else {
1398                    Ok(AwsResponse::json(
1399                        StatusCode::ACCEPTED,
1400                        json!({ "Status": 202 }).to_string(),
1401                    ))
1402                }
1403            }
1404            "PublishVersion" => {
1405                self.publish_version(resource_name.as_deref().unwrap_or(""), aid, &req)
1406            }
1407            "AddPermission" => self.add_permission(resource_name.as_deref().unwrap_or(""), &req),
1408            "GetPolicy" => self.get_policy(
1409                resource_name.as_deref().unwrap_or(""),
1410                aid,
1411                req.query_params.get("Qualifier").map(String::as_str),
1412            ),
1413            "RemovePermission" => {
1414                // Path: /2015-03-31/functions/{name}/policy/{sid}
1415                let sid = req.path_segments.get(4).cloned().unwrap_or_default();
1416                self.remove_permission(
1417                    resource_name.as_deref().unwrap_or(""),
1418                    &sid,
1419                    aid,
1420                    req.query_params.get("Qualifier").map(String::as_str),
1421                )
1422            }
1423            "CreateEventSourceMapping" => self.create_event_source_mapping(&req),
1424            "ListEventSourceMappings" => {
1425                // `FunctionName` is an optional httpQuery member, but
1426                // when present it must satisfy `length 1..140` like
1427                // every other `FunctionName` slot in the API.
1428                if let Some(fn_name) = req.query_params.get("FunctionName") {
1429                    let len = fn_name.chars().count();
1430                    if fn_name.is_empty() || len > 140 {
1431                        return Err(AwsServiceError::aws_error(
1432                            StatusCode::BAD_REQUEST,
1433                            "InvalidParameterValueException",
1434                            "FunctionName must be 1..140 characters",
1435                        ));
1436                    }
1437                }
1438                self.list_event_source_mappings(aid, &req)
1439            }
1440            "GetEventSourceMapping" => {
1441                self.get_event_source_mapping(resource_name.as_deref().unwrap_or(""), aid)
1442            }
1443            "DeleteEventSourceMapping" => {
1444                self.delete_event_source_mapping(resource_name.as_deref().unwrap_or(""), aid)
1445            }
1446            "CreateCapacityProvider" => {
1447                crate::workflows::create_capacity_provider(&self.state, &req, &req.json_body())
1448            }
1449            "GetCapacityProvider" => crate::workflows::get_capacity_provider(
1450                &self.state,
1451                &req,
1452                resource_name.as_deref().unwrap_or(""),
1453            ),
1454            "ListCapacityProviders" => crate::workflows::list_capacity_providers(&self.state, &req),
1455            "UpdateCapacityProvider" => crate::workflows::update_capacity_provider(
1456                &self.state,
1457                &req,
1458                resource_name.as_deref().unwrap_or(""),
1459                &req.json_body(),
1460            ),
1461            "DeleteCapacityProvider" => crate::workflows::delete_capacity_provider(
1462                &self.state,
1463                &req,
1464                resource_name.as_deref().unwrap_or(""),
1465            ),
1466            "ListFunctionVersionsByCapacityProvider" => {
1467                crate::workflows::list_function_versions_by_capacity_provider(
1468                    &self.state,
1469                    &req,
1470                    resource_name.as_deref().unwrap_or(""),
1471                )
1472            }
1473            "GetDurableExecution" => crate::workflows::get_durable_execution(
1474                &self.state,
1475                &req,
1476                resource_name.as_deref().unwrap_or(""),
1477            ),
1478            "GetDurableExecutionHistory" => crate::workflows::get_durable_execution_history(
1479                &self.state,
1480                &req,
1481                resource_name.as_deref().unwrap_or(""),
1482            ),
1483            "GetDurableExecutionState" => crate::workflows::get_durable_execution_state(
1484                &self.state,
1485                &req,
1486                resource_name.as_deref().unwrap_or(""),
1487            ),
1488            "ListDurableExecutionsByFunction" => {
1489                crate::workflows::list_durable_executions_by_function(
1490                    &self.state,
1491                    &req,
1492                    resource_name.as_deref().unwrap_or(""),
1493                )
1494            }
1495            "CheckpointDurableExecution" => crate::workflows::checkpoint_durable_execution(
1496                &self.state,
1497                &req,
1498                resource_name.as_deref().unwrap_or(""),
1499                &req.json_body(),
1500            ),
1501            "StopDurableExecution" => crate::workflows::stop_durable_execution(
1502                &self.state,
1503                &req,
1504                resource_name.as_deref().unwrap_or(""),
1505            ),
1506            "SendDurableExecutionCallbackSuccess" => crate::workflows::send_callback_success(
1507                &self.state,
1508                &req,
1509                resource_name.as_deref().unwrap_or(""),
1510            ),
1511            "SendDurableExecutionCallbackFailure" => crate::workflows::send_callback_failure(
1512                &self.state,
1513                &req,
1514                resource_name.as_deref().unwrap_or(""),
1515            ),
1516            "SendDurableExecutionCallbackHeartbeat" => crate::workflows::send_callback_heartbeat(
1517                &self.state,
1518                &req,
1519                resource_name.as_deref().unwrap_or(""),
1520            ),
1521            other => {
1522                self.handle_extra(other, resource_name.as_deref(), &req)
1523                    .await
1524            }
1525        };
1526        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1527            self.save_snapshot().await;
1528        }
1529        result
1530    }
1531
1532    fn supported_actions(&self) -> &[&str] {
1533        &[
1534            "CreateFunction",
1535            "GetFunction",
1536            "DeleteFunction",
1537            "ListFunctions",
1538            "Invoke",
1539            "InvokeAsync",
1540            "InvokeWithResponseStream",
1541            "PublishVersion",
1542            "ListVersionsByFunction",
1543            "AddPermission",
1544            "RemovePermission",
1545            "GetPolicy",
1546            "CreateEventSourceMapping",
1547            "ListEventSourceMappings",
1548            "GetEventSourceMapping",
1549            "UpdateEventSourceMapping",
1550            "DeleteEventSourceMapping",
1551            "GetFunctionConfiguration",
1552            "UpdateFunctionConfiguration",
1553            "UpdateFunctionCode",
1554            "GetAccountSettings",
1555            "CreateAlias",
1556            "GetAlias",
1557            "ListAliases",
1558            "UpdateAlias",
1559            "DeleteAlias",
1560            "PublishLayerVersion",
1561            "GetLayerVersion",
1562            "GetLayerVersionByArn",
1563            "DeleteLayerVersion",
1564            "ListLayerVersions",
1565            "ListLayers",
1566            "GetLayerVersionPolicy",
1567            "AddLayerVersionPermission",
1568            "RemoveLayerVersionPermission",
1569            "CreateFunctionUrlConfig",
1570            "GetFunctionUrlConfig",
1571            "UpdateFunctionUrlConfig",
1572            "DeleteFunctionUrlConfig",
1573            "ListFunctionUrlConfigs",
1574            "PutFunctionConcurrency",
1575            "GetFunctionConcurrency",
1576            "DeleteFunctionConcurrency",
1577            "PutProvisionedConcurrencyConfig",
1578            "GetProvisionedConcurrencyConfig",
1579            "DeleteProvisionedConcurrencyConfig",
1580            "ListProvisionedConcurrencyConfigs",
1581            "CreateCodeSigningConfig",
1582            "GetCodeSigningConfig",
1583            "UpdateCodeSigningConfig",
1584            "DeleteCodeSigningConfig",
1585            "ListCodeSigningConfigs",
1586            "PutFunctionCodeSigningConfig",
1587            "GetFunctionCodeSigningConfig",
1588            "DeleteFunctionCodeSigningConfig",
1589            "ListFunctionsByCodeSigningConfig",
1590            "PutFunctionEventInvokeConfig",
1591            "GetFunctionEventInvokeConfig",
1592            "UpdateFunctionEventInvokeConfig",
1593            "DeleteFunctionEventInvokeConfig",
1594            "ListFunctionEventInvokeConfigs",
1595            "PutRuntimeManagementConfig",
1596            "GetRuntimeManagementConfig",
1597            "PutFunctionScalingConfig",
1598            "GetFunctionScalingConfig",
1599            "PutFunctionRecursionConfig",
1600            "GetFunctionRecursionConfig",
1601            "TagResource",
1602            "UntagResource",
1603            "ListTags",
1604            "CreateCapacityProvider",
1605            "GetCapacityProvider",
1606            "ListCapacityProviders",
1607            "UpdateCapacityProvider",
1608            "DeleteCapacityProvider",
1609            "ListFunctionVersionsByCapacityProvider",
1610            "GetDurableExecution",
1611            "GetDurableExecutionHistory",
1612            "GetDurableExecutionState",
1613            "ListDurableExecutionsByFunction",
1614            "CheckpointDurableExecution",
1615            "StopDurableExecution",
1616            "SendDurableExecutionCallbackSuccess",
1617            "SendDurableExecutionCallbackFailure",
1618            "SendDurableExecutionCallbackHeartbeat",
1619        ]
1620    }
1621
1622    fn iam_enforceable(&self) -> bool {
1623        true
1624    }
1625
1626    /// Lambda resources are function ARNs. Function-scoped ops
1627    /// resolve the target ARN from the path; list ops target `*`
1628    /// (the whole service), matching how AWS models them.
1629    fn iam_action_for(&self, request: &AwsRequest) -> Option<fakecloud_core::auth::IamAction> {
1630        // REST-JSON services don't have `request.action` populated at
1631        // dispatch time — it's derived from method+path inside
1632        // `handle()`. Reuse the same resolver so the two can never
1633        // drift.
1634        let (action_str, resource_name) = Self::resolve_action(request)?;
1635        // Every op that `resolve_action` (and therefore `handle`) can
1636        // dispatch MUST map to an IAM action here. Lambda is
1637        // `iam_enforceable`, so an unmapped op returning `None` would run
1638        // with zero policy evaluation — a silent auth bypass. We drive
1639        // this from the same op-name strings the dispatcher uses so the
1640        // two can't drift; the exhaustiveness test asserts every
1641        // dispatchable op resolves to `Some`.
1642        let action = iam_action_name_for(action_str)?;
1643        let accounts = self.state.read();
1644        let empty = LambdaState::new(&request.account_id, &request.region);
1645        let state = accounts.get(&request.account_id).unwrap_or(&empty);
1646        let resource = match action_str {
1647            // Function-scoped ops: the path identifier is a function name
1648            // (or ARN/partial-ARN). Normalize it to a bare name and build
1649            // the canonical function ARN so policy evaluation matches the
1650            // real function regardless of how the caller spelled it.
1651            "GetFunction"
1652            | "DeleteFunction"
1653            | "Invoke"
1654            | "InvokeAsync"
1655            | "InvokeWithResponseStream"
1656            | "PublishVersion"
1657            | "ListVersionsByFunction"
1658            | "AddPermission"
1659            | "RemovePermission"
1660            | "GetPolicy"
1661            | "GetFunctionConfiguration"
1662            | "UpdateFunctionConfiguration"
1663            | "UpdateFunctionCode"
1664            | "CreateAlias"
1665            | "GetAlias"
1666            | "UpdateAlias"
1667            | "DeleteAlias"
1668            | "ListAliases"
1669            | "PutFunctionConcurrency"
1670            | "GetFunctionConcurrency"
1671            | "DeleteFunctionConcurrency"
1672            | "PutProvisionedConcurrencyConfig"
1673            | "GetProvisionedConcurrencyConfig"
1674            | "DeleteProvisionedConcurrencyConfig"
1675            | "ListProvisionedConcurrencyConfigs"
1676            | "PutFunctionEventInvokeConfig"
1677            | "GetFunctionEventInvokeConfig"
1678            | "UpdateFunctionEventInvokeConfig"
1679            | "DeleteFunctionEventInvokeConfig"
1680            | "ListFunctionEventInvokeConfigs"
1681            | "PutRuntimeManagementConfig"
1682            | "GetRuntimeManagementConfig"
1683            | "PutFunctionScalingConfig"
1684            | "GetFunctionScalingConfig"
1685            | "PutFunctionRecursionConfig"
1686            | "GetFunctionRecursionConfig"
1687            | "PutFunctionCodeSigningConfig"
1688            | "GetFunctionCodeSigningConfig"
1689            | "DeleteFunctionCodeSigningConfig"
1690            | "CreateFunctionUrlConfig"
1691            | "GetFunctionUrlConfig"
1692            | "UpdateFunctionUrlConfig"
1693            | "DeleteFunctionUrlConfig"
1694            | "ListFunctionUrlConfigs"
1695            | "ListDurableExecutionsByFunction" => {
1696                let raw = resource_name.unwrap_or_default();
1697                if raw.is_empty() {
1698                    "*".to_string()
1699                } else {
1700                    // Normalize ARN / `function:Name` / partial-ARN
1701                    // inputs to bare names — IAM resource derivation
1702                    // must produce the same ARN regardless of how the
1703                    // caller spelled FunctionName, or policy evaluation
1704                    // mismatches the actual function.
1705                    let name = normalize_function_name(&raw);
1706                    format!(
1707                        "arn:aws:lambda:{}:{}:function:{}",
1708                        state.region, state.account_id, name
1709                    )
1710                }
1711            }
1712            "CreateFunction" => {
1713                // Best-effort: parse the FunctionName from the body so
1714                // CreateFunction can be resource-scoped against the
1715                // to-be-created ARN. Falls back to `*` when the body
1716                // isn't JSON yet (e.g. soft-mode observability).
1717                serde_json::from_slice::<Value>(&request.body)
1718                    .ok()
1719                    .and_then(|v| {
1720                        v.get("FunctionName").and_then(|f| f.as_str()).map(|n| {
1721                            format!(
1722                                "arn:aws:lambda:{}:{}:function:{}",
1723                                state.region, state.account_id, n
1724                            )
1725                        })
1726                    })
1727                    .unwrap_or_else(|| "*".to_string())
1728            }
1729            _ => "*".to_string(),
1730        };
1731        Some(fakecloud_core::auth::IamAction {
1732            service: "lambda",
1733            action,
1734            resource,
1735        })
1736    }
1737
1738    fn iam_condition_keys_for(
1739        &self,
1740        request: &AwsRequest,
1741        action: &fakecloud_core::auth::IamAction,
1742    ) -> std::collections::BTreeMap<String, Vec<String>> {
1743        let mut out = std::collections::BTreeMap::new();
1744        if action.action == "AddPermission" {
1745            if action.resource != "*" {
1746                out.insert(
1747                    "lambda:functionarn".to_string(),
1748                    vec![action.resource.clone()],
1749                );
1750            }
1751            if let Ok(body) = serde_json::from_slice::<Value>(&request.body) {
1752                if let Some(principal) = body.get("Principal").and_then(|p| p.as_str()) {
1753                    out.insert("lambda:principal".to_string(), vec![principal.to_string()]);
1754                }
1755            }
1756        }
1757        out
1758    }
1759}
1760
1761#[path = "../service_event_sources.rs"]
1762mod service_event_sources;
1763#[path = "../service_permissions.rs"]
1764mod service_permissions;
1765
1766#[cfg(test)]
1767#[path = "../service_tests.rs"]
1768mod tests;