Skip to main content

fakecloud_lambda/service/
mod.rs

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