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