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            // ListAliases' MaxItems range is 1..10000 in the Smithy model (only
1241            // the layer / url-config / provisioned-concurrency / event-invoke
1242            // lists cap at 50), so it must not be grouped with the 50-cap ops.
1243            let max = match action {
1244                "ListLayers"
1245                | "ListLayerVersions"
1246                | "ListFunctionUrlConfigs"
1247                | "ListProvisionedConcurrencyConfigs"
1248                | "ListFunctionEventInvokeConfigs" => 50,
1249                _ => 10000,
1250            };
1251            if !(1..=max).contains(&n) {
1252                return Err(AwsServiceError::aws_error(
1253                    StatusCode::BAD_REQUEST,
1254                    "InvalidParameterValueException",
1255                    format!("MaxItems must be between 1 and {} (got {})", max, n),
1256                ));
1257            }
1258        }
1259
1260        // Smithy `Qualifier` shape is `length 1..128`. Probe variants
1261        // exercise the lower boundary by sending the empty string;
1262        // reject pre-dispatch so every per-handler `parse_qualifier`
1263        // call doesn't need its own check.
1264        if let Some(q) = req.query_params.get("Qualifier") {
1265            let len = q.chars().count();
1266            if q.is_empty() || len > 128 {
1267                return Err(AwsServiceError::aws_error(
1268                    StatusCode::BAD_REQUEST,
1269                    "InvalidParameterValueException",
1270                    format!("Qualifier must be 1..128 characters (got length {})", len),
1271                ));
1272            }
1273        }
1274        // Same guard for the `FunctionVersion` query member used by
1275        // `ListAliases` (`length 1..1024` / pattern `(\\$LATEST|[0-9]+)`).
1276        if let Some(fv) = req.query_params.get("FunctionVersion") {
1277            let len = fv.chars().count();
1278            if fv.is_empty() || len > 1024 {
1279                return Err(AwsServiceError::aws_error(
1280                    StatusCode::BAD_REQUEST,
1281                    "InvalidParameterValueException",
1282                    format!(
1283                        "FunctionVersion must be 1..1024 characters (got length {})",
1284                        len
1285                    ),
1286                ));
1287            }
1288        }
1289
1290        let mutates = matches!(
1291            action,
1292            "CreateFunction"
1293                | "DeleteFunction"
1294                | "PublishVersion"
1295                | "AddPermission"
1296                | "RemovePermission"
1297                | "CreateEventSourceMapping"
1298                | "DeleteEventSourceMapping"
1299                | "UpdateEventSourceMapping"
1300                | "UpdateFunctionCode"
1301                | "UpdateFunctionConfiguration"
1302                | "CreateAlias"
1303                | "DeleteAlias"
1304                | "UpdateAlias"
1305                | "PublishLayerVersion"
1306                | "DeleteLayerVersion"
1307                | "AddLayerVersionPermission"
1308                | "RemoveLayerVersionPermission"
1309                | "CreateFunctionUrlConfig"
1310                | "DeleteFunctionUrlConfig"
1311                | "UpdateFunctionUrlConfig"
1312                | "PutFunctionConcurrency"
1313                | "DeleteFunctionConcurrency"
1314                | "PutProvisionedConcurrencyConfig"
1315                | "DeleteProvisionedConcurrencyConfig"
1316                | "CreateCodeSigningConfig"
1317                | "UpdateCodeSigningConfig"
1318                | "DeleteCodeSigningConfig"
1319                | "PutFunctionCodeSigningConfig"
1320                | "DeleteFunctionCodeSigningConfig"
1321                | "PutFunctionEventInvokeConfig"
1322                | "UpdateFunctionEventInvokeConfig"
1323                | "DeleteFunctionEventInvokeConfig"
1324                | "PutRuntimeManagementConfig"
1325                | "PutFunctionScalingConfig"
1326                | "PutFunctionRecursionConfig"
1327                | "TagResource"
1328                | "UntagResource"
1329                | "InvokeAsync"
1330                | "InvokeWithResponseStream"
1331        );
1332
1333        let aid = &req.account_id;
1334        // Smithy-aligned validation for the handful of input fields whose
1335        // refreshed @length / @range / enum constraints surface as new
1336        // conformance variants. Centralised here so the body parser in each
1337        // handler stays focused on shape transforms.
1338        prevalidate_lambda(action, &req)?;
1339        let result = match action {
1340            "CreateFunction" => self.create_function(&req),
1341            "ListFunctions" => self.list_functions(
1342                aid,
1343                req.query_params.get("FunctionVersion").map(String::as_str),
1344                req.query_params.get("Marker").map(String::as_str),
1345                marker_page_size(&req),
1346            ),
1347            "GetFunction" => self.get_function(
1348                &req,
1349                resource_name.as_deref().unwrap_or(""),
1350                aid,
1351                req.region.as_str(),
1352                req.query_params.get("Qualifier").map(String::as_str),
1353            ),
1354            "DeleteFunction" => self.delete_function(
1355                resource_name.as_deref().unwrap_or(""),
1356                aid,
1357                req.query_params.get("Qualifier").map(String::as_str),
1358            ),
1359            "Invoke" => {
1360                let invocation_type = InvocationType::from_header(
1361                    req.headers
1362                        .get("x-amz-invocation-type")
1363                        .and_then(|v| v.to_str().ok()),
1364                );
1365                let log_tail = req
1366                    .headers
1367                    .get("x-amz-log-type")
1368                    .and_then(|v| v.to_str().ok())
1369                    == Some("Tail");
1370                // `?Qualifier=` wins; otherwise honor a qualifier embedded in
1371                // the function ARN/ref (1.3).
1372                let qualifier = req
1373                    .query_params
1374                    .get("Qualifier")
1375                    .map(String::as_str)
1376                    .or(arn_embedded_qualifier.as_deref());
1377                self.invoke(
1378                    resource_name.as_deref().unwrap_or(""),
1379                    &req.body,
1380                    aid,
1381                    invocation_type,
1382                    qualifier,
1383                    log_tail,
1384                )
1385                .await
1386            }
1387            "InvokeAsync" => {
1388                // `InvokeAsync` is deprecated. AWS returns 202 with a
1389                // `Status` body and never surfaces synchronous-invoke
1390                // errors (`InvalidParameterValueException` isn't in
1391                // the op's declared error envelope). Validate the
1392                // function exists, then enqueue is a no-op.
1393                let name = resource_name.as_deref().unwrap_or("");
1394                let accounts = self.state.read();
1395                let exists = accounts
1396                    .get(aid)
1397                    .map(|s| s.functions.contains_key(name))
1398                    .unwrap_or(false);
1399                if !exists {
1400                    Err(AwsServiceError::aws_error(
1401                        StatusCode::NOT_FOUND,
1402                        "ResourceNotFoundException",
1403                        format!("Function not found: {}", name),
1404                    ))
1405                } else {
1406                    Ok(AwsResponse::json(
1407                        StatusCode::ACCEPTED,
1408                        json!({ "Status": 202 }).to_string(),
1409                    ))
1410                }
1411            }
1412            "PublishVersion" => {
1413                self.publish_version(resource_name.as_deref().unwrap_or(""), aid, &req)
1414            }
1415            "AddPermission" => self.add_permission(resource_name.as_deref().unwrap_or(""), &req),
1416            "GetPolicy" => self.get_policy(
1417                resource_name.as_deref().unwrap_or(""),
1418                aid,
1419                req.query_params.get("Qualifier").map(String::as_str),
1420            ),
1421            "RemovePermission" => {
1422                // Path: /2015-03-31/functions/{name}/policy/{sid}
1423                let sid = req.path_segments.get(4).cloned().unwrap_or_default();
1424                self.remove_permission(
1425                    resource_name.as_deref().unwrap_or(""),
1426                    &sid,
1427                    aid,
1428                    req.query_params.get("Qualifier").map(String::as_str),
1429                )
1430            }
1431            "CreateEventSourceMapping" => self.create_event_source_mapping(&req),
1432            "ListEventSourceMappings" => {
1433                // `FunctionName` is an optional httpQuery member, but
1434                // when present it must satisfy `length 1..140` like
1435                // every other `FunctionName` slot in the API.
1436                if let Some(fn_name) = req.query_params.get("FunctionName") {
1437                    let len = fn_name.chars().count();
1438                    if fn_name.is_empty() || len > 140 {
1439                        return Err(AwsServiceError::aws_error(
1440                            StatusCode::BAD_REQUEST,
1441                            "InvalidParameterValueException",
1442                            "FunctionName must be 1..140 characters",
1443                        ));
1444                    }
1445                }
1446                self.list_event_source_mappings(aid, &req)
1447            }
1448            "GetEventSourceMapping" => {
1449                self.get_event_source_mapping(resource_name.as_deref().unwrap_or(""), aid)
1450            }
1451            "DeleteEventSourceMapping" => {
1452                self.delete_event_source_mapping(resource_name.as_deref().unwrap_or(""), aid)
1453            }
1454            "CreateCapacityProvider" => {
1455                crate::workflows::create_capacity_provider(&self.state, &req, &req.json_body())
1456            }
1457            "GetCapacityProvider" => crate::workflows::get_capacity_provider(
1458                &self.state,
1459                &req,
1460                resource_name.as_deref().unwrap_or(""),
1461            ),
1462            "ListCapacityProviders" => crate::workflows::list_capacity_providers(&self.state, &req),
1463            "UpdateCapacityProvider" => crate::workflows::update_capacity_provider(
1464                &self.state,
1465                &req,
1466                resource_name.as_deref().unwrap_or(""),
1467                &req.json_body(),
1468            ),
1469            "DeleteCapacityProvider" => crate::workflows::delete_capacity_provider(
1470                &self.state,
1471                &req,
1472                resource_name.as_deref().unwrap_or(""),
1473            ),
1474            "ListFunctionVersionsByCapacityProvider" => {
1475                crate::workflows::list_function_versions_by_capacity_provider(
1476                    &self.state,
1477                    &req,
1478                    resource_name.as_deref().unwrap_or(""),
1479                )
1480            }
1481            "GetDurableExecution" => crate::workflows::get_durable_execution(
1482                &self.state,
1483                &req,
1484                resource_name.as_deref().unwrap_or(""),
1485            ),
1486            "GetDurableExecutionHistory" => crate::workflows::get_durable_execution_history(
1487                &self.state,
1488                &req,
1489                resource_name.as_deref().unwrap_or(""),
1490            ),
1491            "GetDurableExecutionState" => crate::workflows::get_durable_execution_state(
1492                &self.state,
1493                &req,
1494                resource_name.as_deref().unwrap_or(""),
1495            ),
1496            "ListDurableExecutionsByFunction" => {
1497                crate::workflows::list_durable_executions_by_function(
1498                    &self.state,
1499                    &req,
1500                    resource_name.as_deref().unwrap_or(""),
1501                )
1502            }
1503            "CheckpointDurableExecution" => crate::workflows::checkpoint_durable_execution(
1504                &self.state,
1505                &req,
1506                resource_name.as_deref().unwrap_or(""),
1507                &req.json_body(),
1508            ),
1509            "StopDurableExecution" => crate::workflows::stop_durable_execution(
1510                &self.state,
1511                &req,
1512                resource_name.as_deref().unwrap_or(""),
1513            ),
1514            "SendDurableExecutionCallbackSuccess" => crate::workflows::send_callback_success(
1515                &self.state,
1516                &req,
1517                resource_name.as_deref().unwrap_or(""),
1518            ),
1519            "SendDurableExecutionCallbackFailure" => crate::workflows::send_callback_failure(
1520                &self.state,
1521                &req,
1522                resource_name.as_deref().unwrap_or(""),
1523            ),
1524            "SendDurableExecutionCallbackHeartbeat" => crate::workflows::send_callback_heartbeat(
1525                &self.state,
1526                &req,
1527                resource_name.as_deref().unwrap_or(""),
1528            ),
1529            other => {
1530                self.handle_extra(other, resource_name.as_deref(), &req)
1531                    .await
1532            }
1533        };
1534        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
1535            self.save_snapshot().await;
1536        }
1537        result
1538    }
1539
1540    fn supported_actions(&self) -> &[&str] {
1541        &[
1542            "CreateFunction",
1543            "GetFunction",
1544            "DeleteFunction",
1545            "ListFunctions",
1546            "Invoke",
1547            "InvokeAsync",
1548            "InvokeWithResponseStream",
1549            "PublishVersion",
1550            "ListVersionsByFunction",
1551            "AddPermission",
1552            "RemovePermission",
1553            "GetPolicy",
1554            "CreateEventSourceMapping",
1555            "ListEventSourceMappings",
1556            "GetEventSourceMapping",
1557            "UpdateEventSourceMapping",
1558            "DeleteEventSourceMapping",
1559            "GetFunctionConfiguration",
1560            "UpdateFunctionConfiguration",
1561            "UpdateFunctionCode",
1562            "GetAccountSettings",
1563            "CreateAlias",
1564            "GetAlias",
1565            "ListAliases",
1566            "UpdateAlias",
1567            "DeleteAlias",
1568            "PublishLayerVersion",
1569            "GetLayerVersion",
1570            "GetLayerVersionByArn",
1571            "DeleteLayerVersion",
1572            "ListLayerVersions",
1573            "ListLayers",
1574            "GetLayerVersionPolicy",
1575            "AddLayerVersionPermission",
1576            "RemoveLayerVersionPermission",
1577            "CreateFunctionUrlConfig",
1578            "GetFunctionUrlConfig",
1579            "UpdateFunctionUrlConfig",
1580            "DeleteFunctionUrlConfig",
1581            "ListFunctionUrlConfigs",
1582            "PutFunctionConcurrency",
1583            "GetFunctionConcurrency",
1584            "DeleteFunctionConcurrency",
1585            "PutProvisionedConcurrencyConfig",
1586            "GetProvisionedConcurrencyConfig",
1587            "DeleteProvisionedConcurrencyConfig",
1588            "ListProvisionedConcurrencyConfigs",
1589            "CreateCodeSigningConfig",
1590            "GetCodeSigningConfig",
1591            "UpdateCodeSigningConfig",
1592            "DeleteCodeSigningConfig",
1593            "ListCodeSigningConfigs",
1594            "PutFunctionCodeSigningConfig",
1595            "GetFunctionCodeSigningConfig",
1596            "DeleteFunctionCodeSigningConfig",
1597            "ListFunctionsByCodeSigningConfig",
1598            "PutFunctionEventInvokeConfig",
1599            "GetFunctionEventInvokeConfig",
1600            "UpdateFunctionEventInvokeConfig",
1601            "DeleteFunctionEventInvokeConfig",
1602            "ListFunctionEventInvokeConfigs",
1603            "PutRuntimeManagementConfig",
1604            "GetRuntimeManagementConfig",
1605            "PutFunctionScalingConfig",
1606            "GetFunctionScalingConfig",
1607            "PutFunctionRecursionConfig",
1608            "GetFunctionRecursionConfig",
1609            "TagResource",
1610            "UntagResource",
1611            "ListTags",
1612            "CreateCapacityProvider",
1613            "GetCapacityProvider",
1614            "ListCapacityProviders",
1615            "UpdateCapacityProvider",
1616            "DeleteCapacityProvider",
1617            "ListFunctionVersionsByCapacityProvider",
1618            "GetDurableExecution",
1619            "GetDurableExecutionHistory",
1620            "GetDurableExecutionState",
1621            "ListDurableExecutionsByFunction",
1622            "CheckpointDurableExecution",
1623            "StopDurableExecution",
1624            "SendDurableExecutionCallbackSuccess",
1625            "SendDurableExecutionCallbackFailure",
1626            "SendDurableExecutionCallbackHeartbeat",
1627        ]
1628    }
1629
1630    fn iam_enforceable(&self) -> bool {
1631        true
1632    }
1633
1634    /// Lambda resources are function ARNs. Function-scoped ops
1635    /// resolve the target ARN from the path; list ops target `*`
1636    /// (the whole service), matching how AWS models them.
1637    fn iam_action_for(&self, request: &AwsRequest) -> Option<fakecloud_core::auth::IamAction> {
1638        // REST-JSON services don't have `request.action` populated at
1639        // dispatch time — it's derived from method+path inside
1640        // `handle()`. Reuse the same resolver so the two can never
1641        // drift.
1642        let (action_str, resource_name) = Self::resolve_action(request)?;
1643        // Every op that `resolve_action` (and therefore `handle`) can
1644        // dispatch MUST map to an IAM action here. Lambda is
1645        // `iam_enforceable`, so an unmapped op returning `None` would run
1646        // with zero policy evaluation — a silent auth bypass. We drive
1647        // this from the same op-name strings the dispatcher uses so the
1648        // two can't drift; the exhaustiveness test asserts every
1649        // dispatchable op resolves to `Some`.
1650        let action = iam_action_name_for(action_str)?;
1651        let accounts = self.state.read();
1652        let empty = LambdaState::new(&request.account_id, &request.region);
1653        let state = accounts.get(&request.account_id).unwrap_or(&empty);
1654        let resource = match action_str {
1655            // Function-scoped ops: the path identifier is a function name
1656            // (or ARN/partial-ARN). Normalize it to a bare name and build
1657            // the canonical function ARN so policy evaluation matches the
1658            // real function regardless of how the caller spelled it.
1659            "GetFunction"
1660            | "DeleteFunction"
1661            | "Invoke"
1662            | "InvokeAsync"
1663            | "InvokeWithResponseStream"
1664            | "PublishVersion"
1665            | "ListVersionsByFunction"
1666            | "AddPermission"
1667            | "RemovePermission"
1668            | "GetPolicy"
1669            | "GetFunctionConfiguration"
1670            | "UpdateFunctionConfiguration"
1671            | "UpdateFunctionCode"
1672            | "CreateAlias"
1673            | "GetAlias"
1674            | "UpdateAlias"
1675            | "DeleteAlias"
1676            | "ListAliases"
1677            | "PutFunctionConcurrency"
1678            | "GetFunctionConcurrency"
1679            | "DeleteFunctionConcurrency"
1680            | "PutProvisionedConcurrencyConfig"
1681            | "GetProvisionedConcurrencyConfig"
1682            | "DeleteProvisionedConcurrencyConfig"
1683            | "ListProvisionedConcurrencyConfigs"
1684            | "PutFunctionEventInvokeConfig"
1685            | "GetFunctionEventInvokeConfig"
1686            | "UpdateFunctionEventInvokeConfig"
1687            | "DeleteFunctionEventInvokeConfig"
1688            | "ListFunctionEventInvokeConfigs"
1689            | "PutRuntimeManagementConfig"
1690            | "GetRuntimeManagementConfig"
1691            | "PutFunctionScalingConfig"
1692            | "GetFunctionScalingConfig"
1693            | "PutFunctionRecursionConfig"
1694            | "GetFunctionRecursionConfig"
1695            | "PutFunctionCodeSigningConfig"
1696            | "GetFunctionCodeSigningConfig"
1697            | "DeleteFunctionCodeSigningConfig"
1698            | "CreateFunctionUrlConfig"
1699            | "GetFunctionUrlConfig"
1700            | "UpdateFunctionUrlConfig"
1701            | "DeleteFunctionUrlConfig"
1702            | "ListFunctionUrlConfigs"
1703            | "ListDurableExecutionsByFunction" => {
1704                let raw = resource_name.unwrap_or_default();
1705                if raw.is_empty() {
1706                    "*".to_string()
1707                } else {
1708                    // Normalize ARN / `function:Name` / partial-ARN
1709                    // inputs to bare names — IAM resource derivation
1710                    // must produce the same ARN regardless of how the
1711                    // caller spelled FunctionName, or policy evaluation
1712                    // mismatches the actual function.
1713                    let name = normalize_function_name(&raw);
1714                    format!(
1715                        "arn:aws:lambda:{}:{}:function:{}",
1716                        state.region, state.account_id, name
1717                    )
1718                }
1719            }
1720            "CreateFunction" => {
1721                // Best-effort: parse the FunctionName from the body so
1722                // CreateFunction can be resource-scoped against the
1723                // to-be-created ARN. Falls back to `*` when the body
1724                // isn't JSON yet (e.g. soft-mode observability).
1725                serde_json::from_slice::<Value>(&request.body)
1726                    .ok()
1727                    .and_then(|v| {
1728                        v.get("FunctionName").and_then(|f| f.as_str()).map(|n| {
1729                            format!(
1730                                "arn:aws:lambda:{}:{}:function:{}",
1731                                state.region, state.account_id, n
1732                            )
1733                        })
1734                    })
1735                    .unwrap_or_else(|| "*".to_string())
1736            }
1737            _ => "*".to_string(),
1738        };
1739        Some(fakecloud_core::auth::IamAction {
1740            service: "lambda",
1741            action,
1742            resource,
1743        })
1744    }
1745
1746    fn iam_condition_keys_for(
1747        &self,
1748        request: &AwsRequest,
1749        action: &fakecloud_core::auth::IamAction,
1750    ) -> std::collections::BTreeMap<String, Vec<String>> {
1751        let mut out = std::collections::BTreeMap::new();
1752        if action.action == "AddPermission" {
1753            if action.resource != "*" {
1754                out.insert(
1755                    "lambda:functionarn".to_string(),
1756                    vec![action.resource.clone()],
1757                );
1758            }
1759            if let Ok(body) = serde_json::from_slice::<Value>(&request.body) {
1760                if let Some(principal) = body.get("Principal").and_then(|p| p.as_str()) {
1761                    out.insert("lambda:principal".to_string(), vec![principal.to_string()]);
1762                }
1763            }
1764        }
1765        out
1766    }
1767}
1768
1769#[path = "../service_event_sources.rs"]
1770mod service_event_sources;
1771#[path = "../service_permissions.rs"]
1772mod service_permissions;
1773
1774#[cfg(test)]
1775#[path = "../service_tests.rs"]
1776mod tests;