Skip to main content

fakecloud_lambda/service/
mod.rs

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