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