Skip to main content

fakecloud_lambda/
service.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
20/// Lambda actions whose URL `resource_name` slot is a `FunctionName`
21/// (and therefore accepts ARN / partial ARN / `name:qualifier` forms).
22/// Layer / event-source-mapping / code-signing-config actions key off
23/// other resource identifiers and are excluded.
24pub(crate) fn action_takes_function_name(action: &str) -> bool {
25    matches!(
26        action,
27        "GetFunction"
28            | "DeleteFunction"
29            | "Invoke"
30            | "InvokeAsync"
31            | "InvokeWithResponseStream"
32            | "PublishVersion"
33            | "ListVersionsByFunction"
34            | "AddPermission"
35            | "RemovePermission"
36            | "GetPolicy"
37            | "GetFunctionConfiguration"
38            | "UpdateFunctionConfiguration"
39            | "UpdateFunctionCode"
40            | "GetFunctionConcurrency"
41            | "PutFunctionConcurrency"
42            | "DeleteFunctionConcurrency"
43            | "PutProvisionedConcurrencyConfig"
44            | "GetProvisionedConcurrencyConfig"
45            | "DeleteProvisionedConcurrencyConfig"
46            | "ListProvisionedConcurrencyConfigs"
47            | "PutFunctionEventInvokeConfig"
48            | "UpdateFunctionEventInvokeConfig"
49            | "GetFunctionEventInvokeConfig"
50            | "DeleteFunctionEventInvokeConfig"
51            | "ListFunctionEventInvokeConfigs"
52            | "CreateFunctionUrlConfig"
53            | "UpdateFunctionUrlConfig"
54            | "GetFunctionUrlConfig"
55            | "DeleteFunctionUrlConfig"
56            | "ListFunctionUrlConfigs"
57            | "PutFunctionCodeSigningConfig"
58            | "GetFunctionCodeSigningConfig"
59            | "DeleteFunctionCodeSigningConfig"
60            | "GetFunctionScalingConfig"
61            | "PutFunctionScalingConfig"
62            | "PutFunctionRecursionConfig"
63            | "GetFunctionRecursionConfig"
64            | "CreateAlias"
65            | "GetAlias"
66            | "ListAliases"
67            | "UpdateAlias"
68            | "DeleteAlias"
69            | "PutRuntimeManagementConfig"
70            | "GetRuntimeManagementConfig"
71    )
72}
73
74/// Strip an ARN, partial ARN, or trailing `:qualifier` from a Lambda
75/// `FunctionName` input down to the bare function name used as the
76/// state map key. AWS Lambda accepts four forms in URL path slots and
77/// API params:
78///
79///   - `MyFunction`
80///   - `MyFunction:Qualifier`
81///   - `123456789012:function:MyFunction[:Qualifier]`           (partial ARN)
82///   - `arn:aws:lambda:REGION:ACCOUNT:function:MyFunction[:Qualifier]`
83///
84/// Inputs that don't match any of those structures are returned
85/// unchanged. The qualifier (version or alias) is dropped because most
86/// callers look up the function by name and resolve qualifier
87/// separately.
88pub(crate) fn normalize_function_name(input: &str) -> String {
89    if input.is_empty() {
90        return String::new();
91    }
92
93    // SDKs URL-encode `:` in path segments, so `arn:aws:lambda:...`
94    // arrives as `arn%3Aaws%3Alambda%3A...`. Decode first; legitimate
95    // function names contain no percent-encoded characters, so this is
96    // safe for the bare-name path too.
97    let decoded = percent_encoding::percent_decode_str(input)
98        .decode_utf8_lossy()
99        .into_owned();
100    let input = decoded.as_str();
101
102    // Full ARN: arn:aws:lambda:REGION:ACCOUNT:function:NAME[:QUALIFIER]
103    if let Some(rest) = input.strip_prefix("arn:aws:lambda:") {
104        let parts: Vec<&str> = rest.splitn(5, ':').collect();
105        // parts: [region, account, "function", name, qualifier?]
106        if parts.len() >= 4 && parts[2] == "function" && !parts[3].is_empty() {
107            return parts[3].to_string();
108        }
109        return input.to_string();
110    }
111
112    // Partial ARN: ACCOUNT:function:NAME[:QUALIFIER]
113    let parts: Vec<&str> = input.splitn(4, ':').collect();
114    if parts.len() >= 3 && parts[1] == "function" && parts[0].chars().all(|c| c.is_ascii_digit()) {
115        if !parts[2].is_empty() {
116            return parts[2].to_string();
117        }
118        return input.to_string();
119    }
120
121    // Bare name with qualifier: NAME:QUALIFIER. Only apply when the
122    // input contains exactly one colon and the name part is a valid
123    // Lambda function-name token, so malformed ARNs (e.g. wrong service
124    // or wrong format) fall through unchanged rather than getting their
125    // first colon-segment returned.
126    if input.matches(':').count() == 1 {
127        if let Some((name, _qualifier)) = input.split_once(':') {
128            if !name.is_empty() && name.chars().all(is_function_name_char) {
129                return name.to_string();
130            }
131        }
132    }
133
134    input.to_string()
135}
136
137fn is_function_name_char(c: char) -> bool {
138    c.is_ascii_alphanumeric() || c == '-' || c == '_'
139}
140
141/// AWS bounds `EphemeralStorage.Size` to `[512, 10240]` MiB. Anything
142/// outside that range is rejected at the API edge with
143/// `InvalidParameterValueException`, matching the real Lambda control
144/// plane. Returns the validated size unchanged on success.
145pub(crate) fn validate_ephemeral_storage(size: i64) -> Result<i64, AwsServiceError> {
146    if !(512..=10240).contains(&size) {
147        return Err(AwsServiceError::aws_error(
148            StatusCode::BAD_REQUEST,
149            "InvalidParameterValueException",
150            format!(
151                "Value {size} at 'ephemeralStorage.size' failed to satisfy constraint: \
152                 Member must satisfy constraint: [Member must have value less than or equal to 10240, \
153                 Member must have value greater than or equal to 512]"
154            ),
155        ));
156    }
157    Ok(size)
158}
159
160/// All fields of a `CreateFunction` request, already parsed and
161/// defaulted. The code zip (if any) is eagerly base64-decoded so the
162/// caller can hash it without doing the decode again.
163struct CreateFunctionInput {
164    function_name: String,
165    runtime: String,
166    role: String,
167    handler: String,
168    description: String,
169    timeout: i64,
170    memory_size: i64,
171    package_type: String,
172    tags: BTreeMap<String, String>,
173    environment: BTreeMap<String, String>,
174    architectures: Vec<String>,
175    code_zip: Option<Vec<u8>>,
176    code_fallback: Vec<u8>,
177    image_uri: Option<String>,
178    layer_arns: Vec<String>,
179    tracing_mode: Option<String>,
180    kms_key_arn: Option<String>,
181    ephemeral_storage_size: Option<i64>,
182    vpc_config: Option<serde_json::Value>,
183    snap_start: Option<serde_json::Value>,
184    dead_letter_config_arn: Option<String>,
185    file_system_configs: Vec<serde_json::Value>,
186    logging_config: Option<serde_json::Value>,
187    image_config: Option<serde_json::Value>,
188    durable_config: Option<serde_json::Value>,
189}
190
191impl CreateFunctionInput {
192    fn from_body(body: &Value) -> Result<Self, AwsServiceError> {
193        let function_name = body["FunctionName"]
194            .as_str()
195            .ok_or_else(|| {
196                AwsServiceError::aws_error(
197                    StatusCode::BAD_REQUEST,
198                    "InvalidParameterValueException",
199                    "FunctionName is required",
200                )
201            })?
202            .to_string();
203
204        let tags: BTreeMap<String, String> = body["Tags"]
205            .as_object()
206            .map(|m| {
207                m.iter()
208                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
209                    .collect()
210            })
211            .unwrap_or_default();
212
213        let environment: BTreeMap<String, String> = body["Environment"]["Variables"]
214            .as_object()
215            .map(|m| {
216                m.iter()
217                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
218                    .collect()
219            })
220            .unwrap_or_default();
221
222        let architectures = body["Architectures"]
223            .as_array()
224            .map(|a| {
225                a.iter()
226                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
227                    .collect()
228            })
229            .unwrap_or_else(|| vec!["x86_64".to_string()]);
230
231        let code_zip: Option<Vec<u8>> = match body["Code"]["ZipFile"].as_str() {
232            Some(b64) => Some(
233                base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(
234                    |_| {
235                        AwsServiceError::aws_error(
236                            StatusCode::BAD_REQUEST,
237                            "InvalidParameterValueException",
238                            "Could not decode Code.ZipFile: invalid base64",
239                        )
240                    },
241                )?,
242            ),
243            None => None,
244        };
245
246        let code_fallback = serde_json::to_vec(&body["Code"]).unwrap_or_default();
247
248        let package_type = body["PackageType"].as_str().unwrap_or("Zip").to_string();
249        // ImageUri belongs to `PackageType=Image` functions. Silently
250        // dropping it on `Zip` functions avoids GetFunction returning
251        // ECR code metadata for a Zip-based function (AWS ignores the
252        // field entirely in that case too).
253        let image_uri = if package_type == "Image" {
254            body["Code"]["ImageUri"].as_str().map(String::from)
255        } else {
256            None
257        };
258
259        // PackageType=Image requires Code.ImageUri; PackageType=Zip requires
260        // code content. Reject inconsistent shapes with AWS's error code so
261        // SDK-level validation tests see matching behaviour.
262        if package_type == "Image" && image_uri.is_none() {
263            return Err(AwsServiceError::aws_error(
264                StatusCode::BAD_REQUEST,
265                "InvalidParameterValueException",
266                "Code.ImageUri is required when PackageType is Image",
267            ));
268        }
269
270        let layer_arns: Vec<String> = body["Layers"]
271            .as_array()
272            .map(|arr| {
273                arr.iter()
274                    .filter_map(|v| v.as_str().map(String::from))
275                    .collect()
276            })
277            .unwrap_or_default();
278
279        let tracing_mode = body["TracingConfig"]["Mode"].as_str().map(String::from);
280        let kms_key_arn = body["KMSKeyArn"].as_str().map(String::from);
281        let ephemeral_storage_size = match body["EphemeralStorage"]["Size"].as_i64() {
282            Some(size) => Some(validate_ephemeral_storage(size)?),
283            None => None,
284        };
285        let vpc_config = body["VpcConfig"]
286            .is_object()
287            .then(|| body["VpcConfig"].clone());
288        let snap_start = body["SnapStart"]
289            .is_object()
290            .then(|| body["SnapStart"].clone());
291        let dead_letter_config_arn = body["DeadLetterConfig"]["TargetArn"]
292            .as_str()
293            .map(String::from);
294        let file_system_configs = body["FileSystemConfigs"]
295            .as_array()
296            .cloned()
297            .unwrap_or_default();
298        let logging_config = body["LoggingConfig"]
299            .is_object()
300            .then(|| body["LoggingConfig"].clone());
301        let image_config = body["ImageConfig"]
302            .is_object()
303            .then(|| body["ImageConfig"].clone());
304        let durable_config = body["DurableConfig"]
305            .is_object()
306            .then(|| body["DurableConfig"].clone());
307
308        Ok(Self {
309            function_name,
310            runtime: body["Runtime"].as_str().unwrap_or("python3.12").to_string(),
311            role: body["Role"].as_str().unwrap_or("").to_string(),
312            handler: body["Handler"]
313                .as_str()
314                .unwrap_or("index.handler")
315                .to_string(),
316            description: body["Description"].as_str().unwrap_or("").to_string(),
317            timeout: body["Timeout"].as_i64().unwrap_or(3),
318            memory_size: body["MemorySize"].as_i64().unwrap_or(128),
319            package_type,
320            tags,
321            environment,
322            architectures,
323            code_zip,
324            code_fallback,
325            image_uri,
326            layer_arns,
327            tracing_mode,
328            kms_key_arn,
329            ephemeral_storage_size,
330            vpc_config,
331            snap_start,
332            dead_letter_config_arn,
333            file_system_configs,
334            logging_config,
335            image_config,
336            durable_config,
337        })
338    }
339}
340
341/// AWS Lambda's InvocationType: synchronous, async (event), or dry-run.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum InvocationType {
344    RequestResponse,
345    Event,
346    DryRun,
347}
348
349impl InvocationType {
350    pub fn from_header(value: Option<&str>) -> Self {
351        match value {
352            Some("Event") => Self::Event,
353            Some("DryRun") => Self::DryRun,
354            _ => Self::RequestResponse,
355        }
356    }
357}
358
359/// Route an async-invoke result to the configured OnSuccess / OnFailure
360/// destination. Destination is matched by ARN scheme: SQS, SNS, EventBridge,
361/// or another Lambda. Mirrors the AWS Lambda destinations record schema.
362fn route_to_destination(
363    bus: Arc<fakecloud_core::delivery::DeliveryBus>,
364    function_arn: &str,
365    request_payload: &[u8],
366    result: &Result<Vec<u8>, String>,
367    destination_config: Option<&serde_json::Value>,
368) {
369    let Some(cfg) = destination_config else {
370        return;
371    };
372    let (key, condition, response_value): (&str, &str, serde_json::Value) = match result {
373        Ok(bytes) => (
374            "OnSuccess",
375            "Success",
376            serde_json::from_slice(bytes).unwrap_or(serde_json::Value::Null),
377        ),
378        Err(err) => (
379            "OnFailure",
380            "RetriesExhausted",
381            serde_json::json!({ "errorMessage": err }),
382        ),
383    };
384    let Some(dest) = cfg
385        .get(key)
386        .and_then(|v| v.get("Destination"))
387        .and_then(|v| v.as_str())
388    else {
389        return;
390    };
391    let request_payload_v: serde_json::Value =
392        serde_json::from_slice(request_payload).unwrap_or(serde_json::Value::Null);
393    let record = serde_json::json!({
394        "version": "1.0",
395        "timestamp": chrono::Utc::now().to_rfc3339(),
396        "requestContext": {
397            "requestId": uuid::Uuid::new_v4().to_string(),
398            "functionArn": format!("{function_arn}:$LATEST"),
399            "condition": condition,
400            "approximateInvokeCount": 1,
401        },
402        "requestPayload": request_payload_v,
403        "responseContext": {
404            "statusCode": 200,
405            "executedVersion": "$LATEST",
406        },
407        "responsePayload": response_value,
408    });
409    let body = record.to_string();
410    if dest.contains(":sqs:") {
411        bus.send_to_sqs(dest, &body, &std::collections::HashMap::new());
412    } else if dest.contains(":sns:") {
413        bus.publish_to_sns(dest, &body, None);
414    } else if dest.contains(":lambda:") {
415        let dest = dest.to_string();
416        let payload = body.clone();
417        tokio::spawn(async move {
418            let _ = bus.invoke_lambda(&dest, &payload).await;
419        });
420    } else if dest.contains(":events:") || dest.contains(":eventbridge:") {
421        let detail_type = if result.is_ok() {
422            "Lambda Function Invocation Result - Success"
423        } else {
424            "Lambda Function Invocation Result - Failure"
425        };
426        bus.put_event_to_eventbridge("lambda", detail_type, &body, "default");
427    }
428}
429
430/// Decrements the per-function in-flight counter on drop. Lives as
431/// long as the invocation it gates — for synchronous invokes that's
432/// the function call's stack frame; for `Event` invokes the guard is
433/// moved into the spawned task so the counter drops only when the
434/// async work finishes.
435pub(crate) struct ConcurrencyGuard {
436    pub(crate) map: Arc<parking_lot::RwLock<BTreeMap<String, i64>>>,
437    pub(crate) key: String,
438}
439
440impl Drop for ConcurrencyGuard {
441    fn drop(&mut self) {
442        let mut m = self.map.write();
443        let n = m.get(&self.key).copied().unwrap_or(0);
444        if n <= 1 {
445            m.remove(&self.key);
446        } else {
447            m.insert(self.key.clone(), n - 1);
448        }
449    }
450}
451
452/// Map an Invoke `Qualifier` (alias name, numeric version, or
453/// `$LATEST`) to a concrete numeric version string. Aliases with a
454/// `RoutingConfig.AdditionalVersionWeights` table do a weighted pick
455/// across the alias's primary `function_version` plus the additional
456/// True when `prev` is byte-equivalent to `live` for every field
457/// that `PublishVersion` would otherwise capture into a new snapshot.
458/// Used to short-circuit a no-op publish (AWS-style idempotency:
459/// re-publishing without any change returns the previous version
460/// unchanged). The comparison spans code identity (sha + size),
461/// configuration (runtime/handler/role/timeout/memory/env/layers/...)
462/// and every advanced field round-tripped through
463/// `function_config_json`. The caller is responsible for resolving
464/// the `effective_description` (caller-supplied override wins over
465/// the live `$LATEST` description, matching real PublishVersion
466/// semantics).
467fn function_config_unchanged_for_publish(
468    prev: &LambdaFunction,
469    live: &LambdaFunction,
470    effective_description: &str,
471) -> bool {
472    prev.code_sha256 == live.code_sha256
473        && prev.code_size == live.code_size
474        && prev.image_uri == live.image_uri
475        && prev.package_type == live.package_type
476        && prev.runtime == live.runtime
477        && prev.role == live.role
478        && prev.handler == live.handler
479        && prev.description == effective_description
480        && prev.timeout == live.timeout
481        && prev.memory_size == live.memory_size
482        && prev.environment == live.environment
483        && prev.architectures == live.architectures
484        && prev.layers.len() == live.layers.len()
485        && prev
486            .layers
487            .iter()
488            .zip(live.layers.iter())
489            .all(|(a, b)| a.arn == b.arn && a.code_size == b.code_size)
490        && prev.tracing_mode == live.tracing_mode
491        && prev.kms_key_arn == live.kms_key_arn
492        && prev.ephemeral_storage_size == live.ephemeral_storage_size
493        && prev.vpc_config == live.vpc_config
494        && prev.dead_letter_config_arn == live.dead_letter_config_arn
495        && prev.file_system_configs == live.file_system_configs
496        && prev.logging_config == live.logging_config
497        && prev.image_config == live.image_config
498        && prev.signing_profile_version_arn == live.signing_profile_version_arn
499        && prev.signing_job_arn == live.signing_job_arn
500        && prev.runtime_version_config == live.runtime_version_config
501        && snap_start_apply_on_eq(prev.snap_start.as_ref(), live.snap_start.as_ref())
502}
503
504/// Compare two `SnapStart` configs by `ApplyOn` only — that's the
505/// caller-supplied knob. `OptimizationStatus` is server-side state
506/// that PublishVersion mutates on snapshots (flipping to "On" when
507/// ApplyOn=PublishedVersions) while $LATEST stays "Off", so a deep
508/// equality check here would never match on a SnapStart-enabled
509/// function and PublishVersion would never be idempotent. Treating
510/// `None` and `{ApplyOn:"None"}` as equivalent matches AWS, which
511/// emits the latter when the field is unset.
512fn snap_start_apply_on_eq(prev: Option<&Value>, live: Option<&Value>) -> bool {
513    let prev_apply = prev
514        .and_then(|v| v.get("ApplyOn"))
515        .and_then(|v| v.as_str())
516        .unwrap_or("None");
517    let live_apply = live
518        .and_then(|v| v.get("ApplyOn"))
519        .and_then(|v| v.as_str())
520        .unwrap_or("None");
521    prev_apply == live_apply
522}
523
524/// versions in the weight map. Returns `None` for `$LATEST` /
525/// unqualified invokes (caller uses the live `$LATEST` config).
526pub(crate) fn resolve_qualifier_to_version(
527    state: &LambdaState,
528    function_name: &str,
529    qualifier: Option<&str>,
530) -> Option<String> {
531    let q = qualifier?;
532    if q == "$LATEST" {
533        return None;
534    }
535    if q.chars().all(|c| c.is_ascii_digit()) {
536        return Some(q.to_string());
537    }
538    let alias_key = format!("{function_name}:{q}");
539    let alias = state.aliases.get(&alias_key)?;
540    let primary = alias.function_version.clone();
541    let routing = alias
542        .routing_config
543        .as_ref()
544        .and_then(|rc| rc.get("AdditionalVersionWeights"))
545        .and_then(|m| m.as_object());
546    let Some(weights) = routing else {
547        return Some(primary);
548    };
549    // Sum of additional weights ∈ [0,1]; primary gets 1 - sum. Pick
550    // uniformly in [0,1) and walk the cumulative weight axis.
551    let mut additional: Vec<(String, f64)> = Vec::with_capacity(weights.len());
552    let mut sum: f64 = 0.0;
553    for (ver, w) in weights {
554        let weight = w.as_f64().unwrap_or(0.0).clamp(0.0, 1.0);
555        sum += weight;
556        additional.push((ver.clone(), weight));
557    }
558    let primary_weight = (1.0 - sum).max(0.0);
559    let pick: f64 = {
560        // Mix a thread-local LCG state with wall-clock nanos so
561        // back-to-back calls within a single process tick still
562        // produce distinct picks. Invoke routing only needs fairness
563        // over many invokes, not crypto randomness.
564        use std::cell::Cell;
565        thread_local! {
566            static RNG: Cell<u64> = const { Cell::new(0x9E37_79B9_7F4A_7C15) };
567        }
568        let now_nanos = std::time::SystemTime::now()
569            .duration_since(std::time::UNIX_EPOCH)
570            .map(|d| d.as_nanos() as u64)
571            .unwrap_or(0);
572        RNG.with(|cell| {
573            let mut s = cell.get() ^ now_nanos;
574            // splitmix64 step
575            s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
576            let mut z = s;
577            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
578            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
579            z ^= z >> 31;
580            cell.set(s);
581            (z >> 11) as f64 / ((1u64 << 53) as f64)
582        })
583    };
584    let mut acc = primary_weight;
585    if pick < acc {
586        return Some(primary);
587    }
588    for (ver, w) in &additional {
589        acc += w;
590        if pick < acc {
591            return Some(ver.clone());
592        }
593    }
594    Some(primary)
595}
596
597pub struct LambdaService {
598    pub(crate) state: SharedLambdaState,
599    pub(crate) runtime: Option<Arc<ContainerRuntime>>,
600    snapshot_store: Option<Arc<dyn SnapshotStore>>,
601    snapshot_lock: Arc<AsyncMutex<()>>,
602    pub(crate) delivery_bus: Option<Arc<fakecloud_core::delivery::DeliveryBus>>,
603    pub(crate) role_trust_validator: Option<Arc<dyn fakecloud_core::auth::RoleTrustValidator>>,
604    pub(crate) s3_delivery: Option<Arc<dyn fakecloud_core::delivery::S3Delivery>>,
605    /// Per-account-per-function in-flight invocation count, used to
606    /// gate `Invoke` against `PutFunctionConcurrency`'s
607    /// `ReservedConcurrentExecutions` ceiling. Keyed by
608    /// `{account_id}:{function_name}`. Live counter — incremented at
609    /// invoke entry, decremented when the invocation completes (or
610    /// when the spawned async task finishes for `Event` invokes).
611    pub(crate) inflight_invocations: Arc<parking_lot::RwLock<BTreeMap<String, i64>>>,
612}
613
614impl LambdaService {
615    pub fn new(state: SharedLambdaState) -> Self {
616        Self {
617            state,
618            runtime: None,
619            snapshot_store: None,
620            snapshot_lock: Arc::new(AsyncMutex::new(())),
621            delivery_bus: None,
622            role_trust_validator: None,
623            s3_delivery: None,
624            inflight_invocations: Arc::new(parking_lot::RwLock::new(BTreeMap::new())),
625        }
626    }
627
628    pub fn with_s3_delivery(mut self, s3: Arc<dyn fakecloud_core::delivery::S3Delivery>) -> Self {
629        self.s3_delivery = Some(s3);
630        self
631    }
632
633    pub fn with_runtime(mut self, runtime: Arc<ContainerRuntime>) -> Self {
634        self.runtime = Some(runtime);
635        self
636    }
637
638    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
639        self.snapshot_store = Some(store);
640        self
641    }
642
643    pub fn with_delivery_bus(mut self, bus: Arc<fakecloud_core::delivery::DeliveryBus>) -> Self {
644        self.delivery_bus = Some(bus);
645        self
646    }
647
648    pub fn with_role_trust_validator(
649        mut self,
650        validator: Arc<dyn fakecloud_core::auth::RoleTrustValidator>,
651    ) -> Self {
652        self.role_trust_validator = Some(validator);
653        self
654    }
655
656    async fn save_snapshot(&self) {
657        let Some(store) = self.snapshot_store.clone() else {
658            return;
659        };
660        let _guard = self.snapshot_lock.lock().await;
661        let snapshot = LambdaSnapshot {
662            schema_version: LAMBDA_SNAPSHOT_SCHEMA_VERSION,
663            accounts: Some(self.state.read().clone()),
664            state: None,
665        };
666        let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
667            let bytes = serde_json::to_vec(&snapshot)
668                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
669            store.save(&bytes)
670        })
671        .await;
672        match join {
673            Ok(Ok(())) => {}
674            Ok(Err(err)) => tracing::error!(%err, "failed to write lambda snapshot"),
675            Err(err) => tracing::error!(%err, "lambda snapshot task panicked"),
676        }
677    }
678
679    /// Determine the action from the HTTP method and path segments.
680    /// Lambda uses REST-style routing:
681    ///   POST   /2015-03-31/functions                         -> CreateFunction
682    ///   GET    /2015-03-31/functions                         -> ListFunctions
683    ///   GET    /2015-03-31/functions/{name}                  -> GetFunction
684    ///   DELETE /2015-03-31/functions/{name}                  -> DeleteFunction
685    ///   POST   /2015-03-31/functions/{name}/invocations      -> Invoke
686    ///   POST   /2015-03-31/functions/{name}/versions         -> PublishVersion
687    ///   POST   /2015-03-31/event-source-mappings             -> CreateEventSourceMapping
688    ///   GET    /2015-03-31/event-source-mappings             -> ListEventSourceMappings
689    ///   GET    /2015-03-31/event-source-mappings/{uuid}      -> GetEventSourceMapping
690    ///   DELETE /2015-03-31/event-source-mappings/{uuid}      -> DeleteEventSourceMapping
691    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Option<String>)> {
692        let segs = &req.path_segments;
693        if segs.is_empty() {
694            return None;
695        }
696        // The Lambda data API uses many date prefixes (one per
697        // operation family). Recognise any well-formed YYYY-MM-DD
698        // prefix and route based on the path structure that follows.
699        let prefix = segs[0].as_str();
700
701        // Account settings + InvokeAsync — any prefix.
702        if segs.get(1).map(|s| s.as_str()) == Some("account-settings") && req.method == Method::GET
703        {
704            return Some(("GetAccountSettings", None));
705        }
706        if segs.get(1).map(|s| s.as_str()) == Some("functions")
707            && segs.get(3).map(|s| s.as_str()) == Some("invoke-async")
708            && req.method == Method::POST
709        {
710            return Some(("InvokeAsync", segs.get(2).map(|s| s.to_string())));
711        }
712        if segs.get(1).map(|s| s.as_str()) == Some("functions")
713            && segs.get(3).map(|s| s.as_str()) == Some("response-streaming-invocations")
714            && req.method == Method::POST
715        {
716            return Some((
717                "InvokeWithResponseStream",
718                segs.get(2).map(|s| s.to_string()),
719            ));
720        }
721
722        // Concurrency (reserved + provisioned) — any prefix.
723        if segs.get(1).map(|s| s.as_str()) == Some("functions")
724            && segs.get(3).map(|s| s.as_str()) == Some("concurrency")
725        {
726            let res = segs.get(2).map(|s| s.to_string());
727            return match req.method {
728                Method::PUT => Some(("PutFunctionConcurrency", res)),
729                Method::GET => Some(("GetFunctionConcurrency", res)),
730                Method::DELETE => Some(("DeleteFunctionConcurrency", res)),
731                _ => None,
732            };
733        }
734
735        // Provisioned concurrency at any prefix. AWS overloads
736        // `GET /functions/{name}/provisioned-concurrency` on the
737        // `List=ALL` query parameter — with it, the op is
738        // `ListProvisionedConcurrencyConfigs`; without, it's
739        // `GetProvisionedConcurrencyConfig` (which needs a
740        // `Qualifier`). Disambiguate before falling into the
741        // per-method match.
742        if segs.get(1).map(|s| s.as_str()) == Some("functions")
743            && segs.get(3).map(|s| s.as_str()) == Some("provisioned-concurrency")
744        {
745            let res = segs.get(2).map(|s| s.to_string());
746            if req.method == Method::GET && req.query_params.contains_key("List") {
747                return Some(("ListProvisionedConcurrencyConfigs", res));
748            }
749            return match req.method {
750                Method::PUT => Some(("PutProvisionedConcurrencyConfig", res)),
751                Method::GET => Some(("GetProvisionedConcurrencyConfig", res)),
752                Method::DELETE => Some(("DeleteProvisionedConcurrencyConfig", res)),
753                _ => None,
754            };
755        }
756        // Legacy alias path (`provisioned-concurrency-configs`) — kept
757        // for backward compatibility with conformance fixtures that
758        // hand-craft the URL.
759        if segs.get(1).map(|s| s.as_str()) == Some("functions")
760            && segs.get(3).map(|s| s.as_str()) == Some("provisioned-concurrency-configs")
761            && req.method == Method::GET
762        {
763            return Some((
764                "ListProvisionedConcurrencyConfigs",
765                segs.get(2).map(|s| s.to_string()),
766            ));
767        }
768
769        // Event invoke config — any prefix.
770        if segs.get(1).map(|s| s.as_str()) == Some("functions")
771            && segs.get(3).map(|s| s.as_str()) == Some("event-invoke-config")
772        {
773            let res = segs.get(2).map(|s| s.to_string());
774            return match req.method {
775                Method::POST => Some(("PutFunctionEventInvokeConfig", res)),
776                Method::PUT => Some(("UpdateFunctionEventInvokeConfig", res)),
777                Method::GET => Some(("GetFunctionEventInvokeConfig", res)),
778                Method::DELETE => Some(("DeleteFunctionEventInvokeConfig", res)),
779                _ => None,
780            };
781        }
782        if segs.get(1).map(|s| s.as_str()) == Some("functions")
783            && (segs.get(3).map(|s| s.as_str()) == Some("event-invoke-config-list")
784                || (segs.get(3).map(|s| s.as_str()) == Some("event-invoke-config")
785                    && segs.get(4).map(|s| s.as_str()) == Some("list")))
786            && req.method == Method::GET
787        {
788            return Some((
789                "ListFunctionEventInvokeConfigs",
790                segs.get(2).map(|s| s.to_string()),
791            ));
792        }
793
794        // Recursion config — any prefix.
795        if segs.get(1).map(|s| s.as_str()) == Some("functions")
796            && segs.get(3).map(|s| s.as_str()) == Some("recursion-config")
797        {
798            let res = segs.get(2).map(|s| s.to_string());
799            return match req.method {
800                Method::PUT => Some(("PutFunctionRecursionConfig", res)),
801                Method::GET => Some(("GetFunctionRecursionConfig", res)),
802                _ => None,
803            };
804        }
805
806        // Runtime management config — any prefix.
807        if segs.get(1).map(|s| s.as_str()) == Some("functions")
808            && segs.get(3).map(|s| s.as_str()) == Some("runtime-management-config")
809        {
810            let res = segs.get(2).map(|s| s.to_string());
811            return match req.method {
812                Method::PUT => Some(("PutRuntimeManagementConfig", res)),
813                Method::GET => Some(("GetRuntimeManagementConfig", res)),
814                _ => None,
815            };
816        }
817
818        // Code signing config (function and global) — any prefix.
819        if segs.get(1).map(|s| s.as_str()) == Some("functions")
820            && segs.get(3).map(|s| s.as_str()) == Some("code-signing-config")
821        {
822            let res = segs.get(2).map(|s| s.to_string());
823            return match req.method {
824                Method::PUT => Some(("PutFunctionCodeSigningConfig", res)),
825                Method::GET => Some(("GetFunctionCodeSigningConfig", res)),
826                Method::DELETE => Some(("DeleteFunctionCodeSigningConfig", res)),
827                _ => None,
828            };
829        }
830        if segs.get(1).map(|s| s.as_str()) == Some("code-signing-configs") {
831            let res = segs.get(2).map(|s| s.to_string());
832            return match (
833                req.method.clone(),
834                segs.len(),
835                segs.get(3).map(|s| s.as_str()),
836            ) {
837                (Method::POST, 2, _) => Some(("CreateCodeSigningConfig", None)),
838                (Method::GET, 2, _) => Some(("ListCodeSigningConfigs", None)),
839                (Method::GET, 3, _) => Some(("GetCodeSigningConfig", res)),
840                (Method::PUT, 3, _) => Some(("UpdateCodeSigningConfig", res)),
841                (Method::DELETE, 3, _) => Some(("DeleteCodeSigningConfig", res)),
842                (Method::GET, 4, Some("functions")) => {
843                    Some(("ListFunctionsByCodeSigningConfig", res))
844                }
845                _ => None,
846            };
847        }
848
849        // Tags resource ARN at any prefix.
850        if segs.get(1).map(|s| s.as_str()) == Some("tags") && segs.len() >= 3 {
851            let res = segs[2..].join("/");
852            return match req.method {
853                Method::POST => Some(("TagResource", Some(res))),
854                Method::DELETE => Some(("UntagResource", Some(res))),
855                Method::GET => Some(("ListTags", Some(res))),
856                _ => None,
857            };
858        }
859
860        // Function URL config + scaling config (any prefix).
861        if segs.get(1).map(|s| s.as_str()) == Some("functions")
862            && segs.get(3).map(|s| s.as_str()) == Some("url")
863        {
864            let res = segs.get(2).map(|s| s.to_string());
865            return match req.method {
866                Method::POST => Some(("CreateFunctionUrlConfig", res)),
867                Method::GET => Some(("GetFunctionUrlConfig", res)),
868                Method::PUT => Some(("UpdateFunctionUrlConfig", res)),
869                Method::DELETE => Some(("DeleteFunctionUrlConfig", res)),
870                _ => None,
871            };
872        }
873        if segs.get(1).map(|s| s.as_str()) == Some("function-urls") && req.method == Method::GET {
874            return Some(("ListFunctionUrlConfigs", None));
875        }
876        if segs.get(1).map(|s| s.as_str()) == Some("functions")
877            && segs.get(3).map(|s| s.as_str()) == Some("urls")
878            && req.method == Method::GET
879        {
880            return Some(("ListFunctionUrlConfigs", segs.get(2).map(|s| s.to_string())));
881        }
882        // Function scaling config — AWS uses
883        // `/2025-11-30/functions/{name}/function-scaling-config` (and the
884        // earlier `scaling-config` path on event-source-mappings was
885        // a different operation that no longer exists in the SDK).
886        if segs.get(1).map(|s| s.as_str()) == Some("functions")
887            && segs.get(3).map(|s| s.as_str()) == Some("function-scaling-config")
888        {
889            let res = segs.get(2).map(|s| s.to_string());
890            return match req.method {
891                Method::PUT => Some(("PutFunctionScalingConfig", res)),
892                Method::GET => Some(("GetFunctionScalingConfig", res)),
893                _ => None,
894            };
895        }
896
897        // NOTE: concurrency, event-invoke-config, recursion-config, and
898        // code-signing-configs routes are all handled by the prefix-agnostic
899        // blocks above. The previously-present date-specific blocks were
900        // dead code.
901
902        // /2018-10-31/layers
903        if prefix == "2018-10-31" && segs.get(1).map(|s| s.as_str()) == Some("layers") {
904            let layer = segs.get(2).map(|s| s.to_string());
905            let third = segs.get(3).map(|s| s.as_str());
906            let version = segs.get(4).map(|s| s.to_string());
907            return match (&req.method, segs.len(), third, version.is_some()) {
908                (&Method::GET, 2, _, _) => Some(("ListLayers", None)),
909                (&Method::POST, 4, Some("versions"), false) => Some(("PublishLayerVersion", layer)),
910                (&Method::GET, 4, Some("versions"), false) => {
911                    // `GET .../versions` is `ListLayerVersions`; the same
912                    // path with a trailing slash is a malformed
913                    // `GetLayerVersion` call whose `VersionNumber` httpLabel
914                    // was elided. Route there so the handler can reject.
915                    if req.raw_path.ends_with("/versions/") {
916                        Some(("GetLayerVersion", layer))
917                    } else {
918                        Some(("ListLayerVersions", layer))
919                    }
920                }
921                (&Method::GET, 5, Some("versions"), true) => Some(("GetLayerVersion", version)),
922                (&Method::DELETE, 5, Some("versions"), true) => {
923                    Some(("DeleteLayerVersion", version))
924                }
925                // `DELETE .../versions/` (trailing slash, no VersionNumber
926                // segment) is a malformed `DeleteLayerVersion` call — route
927                // there so the handler can reject the missing path label.
928                (&Method::DELETE, 4, Some("versions"), false)
929                    if req.raw_path.ends_with("/versions/") =>
930                {
931                    Some(("DeleteLayerVersion", layer))
932                }
933                (&Method::GET, 6, Some("versions"), true)
934                    if segs.get(5).map(|s| s.as_str()) == Some("policy") =>
935                {
936                    Some(("GetLayerVersionPolicy", version))
937                }
938                (&Method::POST, 6, Some("versions"), true)
939                    if segs.get(5).map(|s| s.as_str()) == Some("policy") =>
940                {
941                    Some(("AddLayerVersionPermission", version))
942                }
943                (&Method::DELETE, 7, Some("versions"), true)
944                    if segs.get(5).map(|s| s.as_str()) == Some("policy") =>
945                {
946                    Some(("RemoveLayerVersionPermission", version))
947                }
948                _ => None,
949            };
950        }
951
952        // /2018-10-31/layers-by-arn
953        if prefix == "2018-10-31"
954            && segs.get(1).map(|s| s.as_str()) == Some("layers-by-arn")
955            && req.method == Method::GET
956        {
957            return Some(("GetLayerVersionByArn", None));
958        }
959
960        // NOTE: 2021-10-31/functions/{name}/url and ListFunctionUrlConfigs
961        // are handled by the prefix-agnostic blocks above.
962
963        if prefix != "2015-03-31" {
964            return None;
965        }
966
967        let collection = segs.get(1).map(|s| s.as_str());
968        let resource = segs.get(2).map(|s| s.to_string());
969        let third = segs.get(3).map(|s| s.as_str());
970        let fourth = segs.get(4).map(|s| s.as_str());
971
972        // NOTE: We intentionally do not try to disambiguate URLs that
973        // collapsed because an httpLabel was elided (e.g.
974        // `/functions/` vs `/functions`). Real AWS treats those as
975        // the same path — both hit `ListFunctions` — and we have
976        // e2e coverage that relies on the trailing-slash behaviour
977        // (`host_routing::unsigned_lambda_list_functions_via_host_header`).
978        // Synthetic conformance probes that elide a required path
979        // identifier are inherently un-faithful to the SDK; accept
980        // the small false-positive count rather than break a real
981        // SDK convention.
982
983        let action = match (&req.method, segs.len(), collection, third) {
984            (&Method::POST, 2, Some("functions"), _) => "CreateFunction",
985            (&Method::GET, 2, Some("functions"), _) => {
986                // `GET .../functions` is `ListFunctions`. `GET .../functions/`
987                // (trailing slash) signals a `GetFunction` call whose
988                // `FunctionName` httpLabel was elided — route there so it
989                // can reject the missing identifier.
990                if req.raw_path.ends_with("/functions/") {
991                    "GetFunction"
992                } else {
993                    "ListFunctions"
994                }
995            }
996            (&Method::GET, 3, Some("functions"), _) => "GetFunction",
997            (&Method::DELETE, 3, Some("functions"), _) => "DeleteFunction",
998            (&Method::POST, 4, Some("functions"), Some("invocations")) => "Invoke",
999            (&Method::POST, 4, Some("functions"), Some("invoke-async")) => "InvokeAsync",
1000            (&Method::POST, 4, Some("functions"), Some("response-streaming-invocations")) => {
1001                "InvokeWithResponseStream"
1002            }
1003            (&Method::POST, 4, Some("functions"), Some("versions")) => "PublishVersion",
1004            (&Method::GET, 4, Some("functions"), Some("versions")) => "ListVersionsByFunction",
1005            (&Method::POST, 4, Some("functions"), Some("policy")) => "AddPermission",
1006            (&Method::GET, 4, Some("functions"), Some("policy")) => "GetPolicy",
1007            (&Method::DELETE, 5, Some("functions"), Some("policy")) => "RemovePermission",
1008            (&Method::POST, 4, Some("functions"), Some("aliases")) => "CreateAlias",
1009            (&Method::GET, 4, Some("functions"), Some("aliases")) => {
1010                // `GET .../aliases` (no trailing slash) is `ListAliases`.
1011                // `GET .../aliases/` (trailing slash) is `GetAlias` with an
1012                // empty `Name` — route there so it can reject the missing
1013                // path label rather than silently degrading to a list.
1014                if req.raw_path.ends_with("/aliases/") {
1015                    "GetAlias"
1016                } else {
1017                    "ListAliases"
1018                }
1019            }
1020            (&Method::GET, 5, Some("functions"), Some("aliases")) => "GetAlias",
1021            (&Method::PUT, 5, Some("functions"), Some("aliases")) => "UpdateAlias",
1022            (&Method::DELETE, 5, Some("functions"), Some("aliases")) => "DeleteAlias",
1023            (&Method::GET, 4, Some("functions"), Some("configuration")) => {
1024                "GetFunctionConfiguration"
1025            }
1026            (&Method::PUT, 4, Some("functions"), Some("configuration")) => {
1027                "UpdateFunctionConfiguration"
1028            }
1029            (&Method::PUT, 4, Some("functions"), Some("code")) => "UpdateFunctionCode",
1030            (&Method::PUT, 4, Some("functions"), Some("concurrency")) => "PutFunctionConcurrency",
1031            (&Method::GET, 4, Some("functions"), Some("concurrency")) => "GetFunctionConcurrency",
1032            (&Method::DELETE, 4, Some("functions"), Some("concurrency")) => {
1033                "DeleteFunctionConcurrency"
1034            }
1035            (&Method::PUT, 4, Some("functions"), Some("provisioned-concurrency")) => {
1036                "PutProvisionedConcurrencyConfig"
1037            }
1038            (&Method::GET, 4, Some("functions"), Some("provisioned-concurrency")) => {
1039                "GetProvisionedConcurrencyConfig"
1040            }
1041            (&Method::DELETE, 4, Some("functions"), Some("provisioned-concurrency")) => {
1042                "DeleteProvisionedConcurrencyConfig"
1043            }
1044            (&Method::GET, 4, Some("functions"), Some("provisioned-concurrency-configs")) => {
1045                "ListProvisionedConcurrencyConfigs"
1046            }
1047            (&Method::PUT, 4, Some("functions"), Some("event-invoke-config")) => {
1048                "UpdateFunctionEventInvokeConfig"
1049            }
1050            (&Method::POST, 4, Some("functions"), Some("event-invoke-config")) => {
1051                "PutFunctionEventInvokeConfig"
1052            }
1053            (&Method::GET, 4, Some("functions"), Some("event-invoke-config")) => {
1054                "GetFunctionEventInvokeConfig"
1055            }
1056            (&Method::DELETE, 4, Some("functions"), Some("event-invoke-config")) => {
1057                "DeleteFunctionEventInvokeConfig"
1058            }
1059            (&Method::GET, 4, Some("functions"), Some("event-invoke-config-list")) => {
1060                "ListFunctionEventInvokeConfigs"
1061            }
1062            (&Method::PUT, 4, Some("functions"), Some("code-signing-config")) => {
1063                "PutFunctionCodeSigningConfig"
1064            }
1065            (&Method::GET, 4, Some("functions"), Some("code-signing-config")) => {
1066                "GetFunctionCodeSigningConfig"
1067            }
1068            (&Method::DELETE, 4, Some("functions"), Some("code-signing-config")) => {
1069                "DeleteFunctionCodeSigningConfig"
1070            }
1071            (&Method::PUT, 4, Some("functions"), Some("runtime-management-config")) => {
1072                "PutRuntimeManagementConfig"
1073            }
1074            (&Method::GET, 4, Some("functions"), Some("runtime-management-config")) => {
1075                "GetRuntimeManagementConfig"
1076            }
1077            (&Method::PUT, 4, Some("functions"), Some("function-scaling-config")) => {
1078                "PutFunctionScalingConfig"
1079            }
1080            (&Method::GET, 4, Some("functions"), Some("function-scaling-config")) => {
1081                "GetFunctionScalingConfig"
1082            }
1083            (&Method::PUT, 4, Some("functions"), Some("recursion-config")) => {
1084                "PutFunctionRecursionConfig"
1085            }
1086            (&Method::GET, 4, Some("functions"), Some("recursion-config")) => {
1087                "GetFunctionRecursionConfig"
1088            }
1089            (&Method::POST, 2, Some("event-source-mappings"), _) => "CreateEventSourceMapping",
1090            (&Method::GET, 2, Some("event-source-mappings"), _) => "ListEventSourceMappings",
1091            (&Method::GET, 3, Some("event-source-mappings"), _) => "GetEventSourceMapping",
1092            (&Method::PUT, 3, Some("event-source-mappings"), _) => "UpdateEventSourceMapping",
1093            (&Method::DELETE, 3, Some("event-source-mappings"), _) => "DeleteEventSourceMapping",
1094            (&Method::POST, 3, Some("tags"), _) => "TagResource",
1095            (&Method::DELETE, 3, Some("tags"), _) => "UntagResource",
1096            (&Method::GET, 3, Some("tags"), _) => "ListTags",
1097            _ => return None,
1098        };
1099        let _ = fourth;
1100
1101        Some((action, resource))
1102    }
1103
1104    fn create_function(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
1105        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1106        let input = CreateFunctionInput::from_body(&body)?;
1107
1108        // Enforce the Smithy length bounds on `FunctionName` (1..=140
1109        // characters; AWS accepts the bare name or any ARN form that
1110        // resolves to <= 140 chars including the ARN prefix). Synthetic
1111        // negative-conformance variants drive empty / 141-char inputs
1112        // through this path, so reject up front rather than persisting
1113        // an invalid record.
1114        let raw = input.function_name.as_str();
1115        if raw.is_empty() || raw.chars().count() > 140 {
1116            return Err(AwsServiceError::aws_error(
1117                StatusCode::BAD_REQUEST,
1118                "InvalidParameterValueException",
1119                format!(
1120                    "1 validation error detected: Value '{}' at 'functionName' failed to \
1121                     satisfy constraint: Member must have length less than or equal to 140",
1122                    raw
1123                ),
1124            ));
1125        }
1126
1127        // PassRole trust-policy check: the supplied execution role must
1128        // have a trust policy that allows lambda.amazonaws.com to call
1129        // sts:AssumeRole. Real AWS rejects with InvalidParameterValueException
1130        // when the trust policy doesn't include the service principal.
1131        if let Some(ref validator) = self.role_trust_validator {
1132            if let Err(err) =
1133                validator.validate(&req.account_id, &input.role, "lambda.amazonaws.com")
1134            {
1135                return Err(AwsServiceError::aws_error(
1136                    StatusCode::BAD_REQUEST,
1137                    "InvalidParameterValueException",
1138                    err.to_string(),
1139                ));
1140            }
1141        }
1142
1143        let mut accounts = self.state.write();
1144        // Pre-resolve layer attachments before re-borrowing accounts mutably.
1145        // Layer ARNs may live in sibling accounts.
1146        let layer_attachments =
1147            crate::extras::resolve_layer_attachments(&accounts, input.layer_arns.clone());
1148        let state = accounts.get_or_create(&req.account_id);
1149
1150        if state.functions.contains_key(&input.function_name) {
1151            return Err(AwsServiceError::aws_error(
1152                StatusCode::CONFLICT,
1153                "ResourceConflictException",
1154                format!("Function already exist: {}", input.function_name),
1155            ));
1156        }
1157
1158        // Hash the actual ZIP bytes when available, falling back to the
1159        // raw Code JSON so image-based functions still get a stable id.
1160        let code_bytes = input.code_zip.as_deref().unwrap_or(&input.code_fallback);
1161        let mut hasher = Sha256::new();
1162        hasher.update(code_bytes);
1163        let hash = hasher.finalize();
1164        let code_sha256 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash);
1165        let code_size = code_bytes.len() as i64;
1166
1167        let function_arn = format!(
1168            "arn:aws:lambda:{}:{}:function:{}",
1169            state.region, state.account_id, input.function_name
1170        );
1171        let now = Utc::now();
1172
1173        let func = LambdaFunction {
1174            function_name: input.function_name.clone(),
1175            function_arn,
1176            runtime: input.runtime,
1177            role: input.role,
1178            handler: input.handler,
1179            description: input.description,
1180            timeout: input.timeout,
1181            memory_size: input.memory_size,
1182            code_sha256,
1183            code_size,
1184            version: "$LATEST".to_string(),
1185            last_modified: now,
1186            tags: input.tags,
1187            environment: input.environment,
1188            architectures: input.architectures,
1189            package_type: input.package_type,
1190            code_zip: input.code_zip,
1191            image_uri: input.image_uri,
1192            policy: None,
1193            layers: layer_attachments,
1194            revision_id: uuid::Uuid::new_v4().to_string(),
1195            tracing_mode: input.tracing_mode,
1196            kms_key_arn: input.kms_key_arn,
1197            ephemeral_storage_size: input.ephemeral_storage_size,
1198            vpc_config: input.vpc_config,
1199            snap_start: input.snap_start,
1200            dead_letter_config_arn: input.dead_letter_config_arn,
1201            file_system_configs: input.file_system_configs,
1202            logging_config: input.logging_config,
1203            image_config: input.image_config,
1204            durable_config: input.durable_config,
1205            signing_profile_version_arn: None,
1206            signing_job_arn: None,
1207            runtime_version_config: None,
1208            master_arn: None,
1209            state_reason: None,
1210            state_reason_code: None,
1211            last_update_status_reason: None,
1212            last_update_status_reason_code: None,
1213        };
1214
1215        let response = self.function_config_json(&func);
1216
1217        state.functions.insert(input.function_name, func);
1218
1219        Ok(AwsResponse::json(StatusCode::CREATED, response.to_string()))
1220    }
1221
1222    fn get_function(
1223        &self,
1224        req: &AwsRequest,
1225        function_name: &str,
1226        account_id: &str,
1227        region: &str,
1228        qualifier: Option<&str>,
1229    ) -> Result<AwsResponse, AwsServiceError> {
1230        if function_name.is_empty() {
1231            return Err(AwsServiceError::aws_error(
1232                StatusCode::BAD_REQUEST,
1233                "InvalidParameterValueException",
1234                "FunctionName is required",
1235            ));
1236        }
1237        let accounts = self.state.read();
1238        let empty = LambdaState::new(account_id, region);
1239        let state = accounts.get(account_id).unwrap_or(&empty);
1240        let live = state.functions.get(function_name).ok_or_else(|| {
1241            AwsServiceError::aws_error(
1242                StatusCode::NOT_FOUND,
1243                "ResourceNotFoundException",
1244                format!(
1245                    "Function not found: arn:aws:lambda:{}:{}:function:{}",
1246                    state.region, state.account_id, function_name
1247                ),
1248            )
1249        })?;
1250
1251        // Resolve the qualifier to either $LATEST (live config) or a
1252        // numbered immutable snapshot. Aliases route through
1253        // `resolve_qualifier_to_version` so weighted aliases still pick
1254        // between the underlying numbered versions.
1255        let resolved_version = resolve_qualifier_to_version(state, function_name, qualifier);
1256        let (func, version_label) = match resolved_version {
1257            None => (live, "$LATEST".to_string()),
1258            Some(v) => {
1259                let snap = state
1260                    .function_version_snapshots
1261                    .get(function_name)
1262                    .and_then(|m| m.get(&v))
1263                    .ok_or_else(|| {
1264                        AwsServiceError::aws_error(
1265                            StatusCode::NOT_FOUND,
1266                            "ResourceNotFoundException",
1267                            format!(
1268                                "Function not found: arn:aws:lambda:{}:{}:function:{}:{v}",
1269                                state.region, state.account_id, function_name
1270                            ),
1271                        )
1272                    })?;
1273                (snap, v)
1274            }
1275        };
1276
1277        let mut config = self.function_config_json(func);
1278        config["Version"] = json!(version_label);
1279        if version_label != "$LATEST" {
1280            config["FunctionArn"] = json!(format!("{}:{version_label}", live.function_arn));
1281            config["MasterArn"] = json!(live.function_arn);
1282        }
1283        let code = if let Some(ref uri) = func.image_uri {
1284            json!({
1285                "ImageUri": uri,
1286                "ResolvedImageUri": uri,
1287                "RepositoryType": "ECR",
1288            })
1289        } else {
1290            // Serve the function's stored ZIP from a fakecloud-hosted route on
1291            // the same authority the SDK used, so AWS Toolkit / `aws lambda
1292            // get-function --query 'Code.Location'` can actually download it.
1293            json!({
1294                "Location": crate::extras::function_code_url(
1295                    req,
1296                    &state.account_id,
1297                    function_name,
1298                    &version_label,
1299                ),
1300                "RepositoryType": "S3",
1301            })
1302        };
1303        let response = json!({
1304            "Code": code,
1305            "Configuration": config,
1306            "Tags": live.tags,
1307        });
1308
1309        Ok(AwsResponse::json(StatusCode::OK, response.to_string()))
1310    }
1311
1312    fn delete_function(
1313        &self,
1314        function_name: &str,
1315        account_id: &str,
1316        qualifier: Option<&str>,
1317    ) -> Result<AwsResponse, AwsServiceError> {
1318        let mut accounts = self.state.write();
1319        let state = accounts.get_or_create(account_id);
1320        let region = state.region.clone();
1321        let account_id_owned = state.account_id.clone();
1322
1323        // Qualifier=N targets a single immutable version snapshot; the
1324        // live $LATEST function and other versions stay put. AWS only
1325        // accepts numeric qualifiers here — alias targets are deleted
1326        // via DeleteAlias, and `$LATEST` is rejected as
1327        // InvalidParameterValueException.
1328        if let Some(q) = qualifier {
1329            if q == "$LATEST" {
1330                return Err(AwsServiceError::aws_error(
1331                    StatusCode::BAD_REQUEST,
1332                    "InvalidParameterValueException",
1333                    "$LATEST version cannot be deleted without deleting the function.",
1334                ));
1335            }
1336            if !q.chars().all(|c| c.is_ascii_digit()) {
1337                return Err(AwsServiceError::aws_error(
1338                    StatusCode::BAD_REQUEST,
1339                    "InvalidParameterValueException",
1340                    format!(
1341                        "Value '{q}' at 'qualifier' failed to satisfy constraint: Member must satisfy regular expression pattern: (|[a-zA-Z0-9$_-]+)"
1342                    ),
1343                ));
1344            }
1345            // Live function must exist or AWS 404s before checking the version.
1346            if !state.functions.contains_key(function_name) {
1347                return Err(AwsServiceError::aws_error(
1348                    StatusCode::NOT_FOUND,
1349                    "ResourceNotFoundException",
1350                    format!(
1351                        "Function not found: arn:aws:lambda:{region}:{account_id_owned}:function:{function_name}:{q}"
1352                    ),
1353                ));
1354            }
1355            let snap_existed = state
1356                .function_version_snapshots
1357                .get_mut(function_name)
1358                .map(|m| m.remove(q).is_some())
1359                .unwrap_or(false);
1360            if !snap_existed {
1361                return Err(AwsServiceError::aws_error(
1362                    StatusCode::NOT_FOUND,
1363                    "ResourceNotFoundException",
1364                    format!(
1365                        "Function not found: arn:aws:lambda:{region}:{account_id_owned}:function:{function_name}:{q}"
1366                    ),
1367                ));
1368            }
1369            // Drop the version from the ordered list too so
1370            // ListVersionsByFunction reflects the deletion.
1371            if let Some(list) = state.function_versions.get_mut(function_name) {
1372                list.retain(|v| v != q);
1373            }
1374            return Ok(AwsResponse::json(StatusCode::NO_CONTENT, ""));
1375        }
1376
1377        if state.functions.remove(function_name).is_none() {
1378            return Err(AwsServiceError::aws_error(
1379                StatusCode::NOT_FOUND,
1380                "ResourceNotFoundException",
1381                format!(
1382                    "Function not found: arn:aws:lambda:{region}:{account_id_owned}:function:{function_name}"
1383                ),
1384            ));
1385        }
1386        // Drop all numbered versions + their snapshots so the function
1387        // is gone end-to-end (AWS deletes everything when no Qualifier
1388        // is supplied).
1389        state.function_versions.remove(function_name);
1390        state.function_version_snapshots.remove(function_name);
1391        // Aliases on this function disappear too.
1392        let prefix = format!("{function_name}:");
1393        state.aliases.retain(|k, _| !k.starts_with(&prefix));
1394
1395        // Clean up any running container for this function
1396        if let Some(ref runtime) = self.runtime {
1397            let rt = runtime.clone();
1398            let name = function_name.to_string();
1399            tokio::spawn(async move { rt.stop_container(&name).await });
1400        }
1401
1402        Ok(AwsResponse::json(StatusCode::NO_CONTENT, ""))
1403    }
1404
1405    fn list_functions(
1406        &self,
1407        account_id: &str,
1408        function_version: Option<&str>,
1409    ) -> Result<AwsResponse, AwsServiceError> {
1410        // `FunctionVersion` is an enum with the single member `ALL`; reject
1411        // any other value rather than silently ignoring it.
1412        if let Some(fv) = function_version {
1413            if fv != "ALL" {
1414                return Err(AwsServiceError::aws_error(
1415                    StatusCode::BAD_REQUEST,
1416                    "InvalidParameterValueException",
1417                    format!("Invalid FunctionVersion value '{}'; expected 'ALL'", fv),
1418                ));
1419            }
1420        }
1421        let accounts = self.state.read();
1422        let empty = LambdaState::new(account_id, "");
1423        let state = accounts.get(account_id).unwrap_or(&empty);
1424        let functions: Vec<Value> = state
1425            .functions
1426            .values()
1427            .map(|f| self.function_config_json(f))
1428            .collect();
1429
1430        // AWS's documented example carries `NextMarker` as a string even
1431        // on the final page. We don't paginate yet, so emit an empty
1432        // string rather than a true sentinel — closer to AWS's
1433        // observed behavior than omitting the field, and string-typed
1434        // so strict shape validators don't trip.
1435        let response = json!({
1436            "Functions": functions,
1437            "NextMarker": "",
1438        });
1439
1440        Ok(AwsResponse::json(StatusCode::OK, response.to_string()))
1441    }
1442
1443    async fn invoke(
1444        &self,
1445        function_name: &str,
1446        payload: &[u8],
1447        account_id: &str,
1448        invocation_type: InvocationType,
1449        qualifier: Option<&str>,
1450    ) -> Result<AwsResponse, AwsServiceError> {
1451        // Resolve qualifier (alias / numeric version / $LATEST) to a
1452        // concrete version string. Aliases with a
1453        // `RoutingConfig.AdditionalVersionWeights` map do a weighted
1454        // pick across `function_version` + the additional versions,
1455        // mirroring AWS canary routing. The resolved version is
1456        // surfaced via `x-amz-executed-version` so callers can verify
1457        // which version actually ran. We always invoke against the
1458        // live `$LATEST` function record for now — version-snapshot
1459        // execution is wired separately once
1460        // `function_version_snapshots` lands.
1461        let resolved_version: Option<String> = {
1462            let accounts = self.state.read();
1463            let empty = LambdaState::new(account_id, "");
1464            let state = accounts.get(account_id).unwrap_or(&empty);
1465            resolve_qualifier_to_version(state, function_name, qualifier)
1466        };
1467        let executed_version = resolved_version
1468            .clone()
1469            .unwrap_or_else(|| "$LATEST".to_string());
1470        let (func, layer_zips) = {
1471            let accounts = self.state.read();
1472            let empty = LambdaState::new(account_id, "");
1473            let state = accounts.get(account_id).unwrap_or(&empty);
1474            // Resolve numbered versions to the immutable snapshot stored
1475            // by PublishVersion so an alias pinned to v1 runs the v1 code
1476            // even after $LATEST is mutated. Falls back to $LATEST when
1477            // the snapshot is missing (legacy state) so we never 404 a
1478            // routable invoke.
1479            let func = match resolved_version.as_deref() {
1480                Some(v) => state
1481                    .function_version_snapshots
1482                    .get(function_name)
1483                    .and_then(|m| m.get(v))
1484                    .cloned()
1485                    .or_else(|| state.functions.get(function_name).cloned()),
1486                None => state.functions.get(function_name).cloned(),
1487            }
1488            .ok_or_else(|| {
1489                AwsServiceError::aws_error(
1490                    StatusCode::NOT_FOUND,
1491                    "ResourceNotFoundException",
1492                    format!(
1493                        "Function not found: arn:aws:lambda:{}:{}:function:{}",
1494                        state.region, state.account_id, function_name
1495                    ),
1496                )
1497            })?;
1498            // Resolve attached layer ARNs to ZIP bytes under the same read
1499            // lock. Layers may live in sibling accounts (cross-account
1500            // attach is legal in AWS); fall back to no bytes for unknown
1501            // ARNs and warn — invoke proceeds without that layer.
1502            let mut layer_zips: Vec<Vec<u8>> = Vec::with_capacity(func.layers.len());
1503            for attached in &func.layers {
1504                let bytes = crate::extras::parse_layer_version_arn(&attached.arn).and_then(
1505                    |(acct, name, ver)| {
1506                        accounts
1507                            .get(&acct)
1508                            .and_then(|s| s.layers.get(&name))
1509                            .and_then(|l| l.versions.iter().find(|v| v.version == ver))
1510                            .and_then(|v| v.code_zip.clone())
1511                    },
1512                );
1513                match bytes {
1514                    Some(b) => layer_zips.push(b),
1515                    None => tracing::warn!(
1516                        function = %function_name,
1517                        layer_arn = %attached.arn,
1518                        "attached layer not resolvable; skipping /opt mount for this layer"
1519                    ),
1520                }
1521            }
1522            (func, layer_zips)
1523        };
1524
1525        // Reserved-concurrency gate runs before code-zip / DryRun
1526        // checks: AWS returns 429 even for functions without a code
1527        // package as long as the cap is already hit, since throttling
1528        // is request-rate, not deployment-state.
1529        let concurrency_key = format!("{account_id}:{function_name}");
1530        let _concurrency_guard = {
1531            let cap = {
1532                let accounts = self.state.read();
1533                accounts
1534                    .get(account_id)
1535                    .and_then(|s| s.function_concurrency.get(function_name).copied())
1536            };
1537            let mut map = self.inflight_invocations.write();
1538            let current = map.get(&concurrency_key).copied().unwrap_or(0);
1539            if let Some(limit) = cap {
1540                if current >= limit {
1541                    // AWS returns the throttle Reason in the body so SDKs
1542                    // can branch on `ReservedFunctionConcurrentInvocationLimitExceeded`
1543                    // versus account-pool limits.
1544                    return Err(AwsServiceError::aws_error_with_fields(
1545                        StatusCode::TOO_MANY_REQUESTS,
1546                        "TooManyRequestsException",
1547                        "Rate Exceeded.",
1548                        vec![(
1549                            "Reason".to_string(),
1550                            "ReservedFunctionConcurrentInvocationLimitExceeded".to_string(),
1551                        )],
1552                    ));
1553                }
1554            }
1555            map.insert(concurrency_key.clone(), current + 1);
1556            ConcurrencyGuard {
1557                map: self.inflight_invocations.clone(),
1558                key: concurrency_key.clone(),
1559            }
1560        };
1561
1562        if func.code_zip.is_none() {
1563            return Err(AwsServiceError::aws_error(
1564                StatusCode::BAD_REQUEST,
1565                "InvalidParameterValueException",
1566                "Function has no deployment package",
1567            ));
1568        }
1569
1570        let invoke_start = std::time::Instant::now();
1571        let dry_run_response = if matches!(invocation_type, InvocationType::DryRun) {
1572            let mut resp = AwsResponse::json(StatusCode::NO_CONTENT, "");
1573            if let Ok(v) = http::header::HeaderValue::from_str(&executed_version) {
1574                resp.headers.insert(
1575                    http::header::HeaderName::from_static("x-amz-executed-version"),
1576                    v,
1577                );
1578            }
1579            Some(resp)
1580        } else {
1581            None
1582        };
1583
1584        let runtime_for_invoke = if dry_run_response.is_some() {
1585            None
1586        } else {
1587            self.runtime.clone()
1588        };
1589
1590        let result: Result<AwsResponse, AwsServiceError> = if let Some(resp) = dry_run_response {
1591            Ok(resp)
1592        } else if let Some(runtime) = runtime_for_invoke {
1593            match invocation_type {
1594                InvocationType::Event => {
1595                    // Fire-and-forget. AWS returns 202 with no body. Move
1596                    // the concurrency guard into the spawned task so the
1597                    // counter only drops once the async invocation
1598                    // actually finishes.
1599                    let runtime = runtime.clone();
1600                    let func_clone = func.clone();
1601                    let payload_vec = payload.to_vec();
1602                    let bus = self.delivery_bus.clone();
1603                    let destination_config = self.lookup_destination_config(&func, account_id);
1604                    let function_arn = func.function_arn.clone();
1605                    let layer_zips_async = layer_zips.clone();
1606                    let async_guard = _concurrency_guard;
1607                    tokio::spawn(async move {
1608                        let _g = async_guard;
1609                        let result = match runtime
1610                            .invoke(&func_clone, &payload_vec, &layer_zips_async)
1611                            .await
1612                        {
1613                            Ok(bytes) => {
1614                                // Lambda runtime returns 200 even on uncaught
1615                                // function errors; the body has errorMessage /
1616                                // errorType. Treat that as failure for routing.
1617                                let parsed: Option<serde_json::Value> =
1618                                    serde_json::from_slice(&bytes).ok();
1619                                let is_error = parsed
1620                                    .as_ref()
1621                                    .and_then(|v| v.as_object())
1622                                    .map(|m| {
1623                                        m.contains_key("errorMessage")
1624                                            || m.contains_key("errorType")
1625                                    })
1626                                    .unwrap_or(false);
1627                                if is_error {
1628                                    let msg = parsed
1629                                        .as_ref()
1630                                        .and_then(|v| v.get("errorMessage"))
1631                                        .and_then(|v| v.as_str())
1632                                        .unwrap_or("function error")
1633                                        .to_string();
1634                                    Err(msg)
1635                                } else {
1636                                    Ok(bytes)
1637                                }
1638                            }
1639                            Err(e) => Err(e.to_string()),
1640                        };
1641                        if let Some(bus) = bus {
1642                            route_to_destination(
1643                                bus,
1644                                &function_arn,
1645                                &payload_vec,
1646                                &result,
1647                                destination_config.as_ref(),
1648                            );
1649                        }
1650                    });
1651                    let mut resp = AwsResponse::json(StatusCode::ACCEPTED, "");
1652                    if let Ok(v) = http::header::HeaderValue::from_str(&executed_version) {
1653                        resp.headers.insert(
1654                            http::header::HeaderName::from_static("x-amz-executed-version"),
1655                            v,
1656                        );
1657                    }
1658                    Ok(resp)
1659                }
1660                InvocationType::RequestResponse | InvocationType::DryRun => {
1661                    match runtime.invoke(&func, payload, &layer_zips).await {
1662                        Ok(response_bytes) => {
1663                            let mut resp = AwsResponse::json(StatusCode::OK, response_bytes);
1664                            if let Ok(v) = http::header::HeaderValue::from_str(&executed_version) {
1665                                resp.headers.insert(
1666                                    http::header::HeaderName::from_static("x-amz-executed-version"),
1667                                    v,
1668                                );
1669                            }
1670                            Ok(resp)
1671                        }
1672                        Err(e) => {
1673                            tracing::error!(function = %function_name, error = %e, "Lambda invocation failed");
1674                            Err(AwsServiceError::aws_error(
1675                                StatusCode::INTERNAL_SERVER_ERROR,
1676                                "ServiceException",
1677                                format!("Lambda execution failed: {e}"),
1678                            ))
1679                        }
1680                    }
1681                }
1682            }
1683        } else {
1684            Err(AwsServiceError::aws_error(
1685                StatusCode::INTERNAL_SERVER_ERROR,
1686                "ServiceException",
1687                "Docker/Podman is required for Lambda execution but is not available",
1688            ))
1689        };
1690
1691        // Publish standard AWS/Lambda metrics: Invocations always, Errors
1692        // when the runtime returned a failure, Duration in milliseconds.
1693        // Mirrors what real Lambda emits and lets fakecloud users wire
1694        // CloudWatch alarms on Lambda errors / latency in tests.
1695        if let Some(bus) = &self.delivery_bus {
1696            let dims: std::collections::BTreeMap<String, String> =
1697                [("FunctionName".to_string(), function_name.to_string())]
1698                    .into_iter()
1699                    .collect();
1700            let now_ms = chrono::Utc::now().timestamp_millis();
1701            let region = {
1702                let accounts = self.state.read();
1703                let empty = LambdaState::new(account_id, "");
1704                accounts
1705                    .get(account_id)
1706                    .map(|s| s.region.clone())
1707                    .unwrap_or_else(|| empty.region)
1708            };
1709            bus.put_cloudwatch_metric(
1710                account_id,
1711                &region,
1712                "AWS/Lambda",
1713                "Invocations",
1714                1.0,
1715                Some("Count"),
1716                dims.clone(),
1717                now_ms,
1718            );
1719            bus.put_cloudwatch_metric(
1720                account_id,
1721                &region,
1722                "AWS/Lambda",
1723                "Duration",
1724                invoke_start.elapsed().as_millis() as f64,
1725                Some("Milliseconds"),
1726                dims.clone(),
1727                now_ms,
1728            );
1729            if result.is_err() {
1730                bus.put_cloudwatch_metric(
1731                    account_id,
1732                    &region,
1733                    "AWS/Lambda",
1734                    "Errors",
1735                    1.0,
1736                    Some("Count"),
1737                    dims,
1738                    now_ms,
1739                );
1740            }
1741        }
1742
1743        result
1744    }
1745
1746    /// Pull EventInvokeConfig.DestinationConfig for the function. The
1747    /// stored key is `<function_name>:<qualifier>`; treat unqualified
1748    /// invokes as the empty qualifier (matches `parse_qualifier` in
1749    /// `extras.rs` when no `Qualifier` is supplied).
1750    fn lookup_destination_config(
1751        &self,
1752        func: &crate::state::LambdaFunction,
1753        account_id: &str,
1754    ) -> Option<serde_json::Value> {
1755        let accounts = self.state.read();
1756        let state = accounts.get(account_id)?;
1757        let key = format!("{}:$LATEST", func.function_name);
1758        state
1759            .event_invoke_configs
1760            .get(&key)
1761            .and_then(|cfg| cfg.destination_config.clone())
1762            .filter(|v| !v.is_null() && !v.as_object().map(|o| o.is_empty()).unwrap_or(false))
1763    }
1764
1765    pub(crate) fn publish_version(
1766        &self,
1767        function_name: &str,
1768        account_id: &str,
1769        req: &AwsRequest,
1770    ) -> Result<AwsResponse, AwsServiceError> {
1771        // Optional preconditions from the body. Both compare the supplied
1772        // value against the live `$LATEST` state; mismatch yields 412
1773        // PreconditionFailedException, matching AWS optimistic-concurrency.
1774        let body: Value = serde_json::from_slice(&req.body).unwrap_or_default();
1775        let supplied_revision = body["RevisionId"].as_str().map(String::from);
1776        let supplied_sha = body["CodeSha256"].as_str().map(String::from);
1777        let description_override = body["Description"].as_str().map(String::from);
1778
1779        let mut accounts = self.state.write();
1780        let state = accounts.get_or_create(account_id);
1781        let func = state.functions.get(function_name).ok_or_else(|| {
1782            AwsServiceError::aws_error(
1783                StatusCode::NOT_FOUND,
1784                "ResourceNotFoundException",
1785                format!(
1786                    "Function not found: arn:aws:lambda:{}:{}:function:{}",
1787                    state.region, state.account_id, function_name
1788                ),
1789            )
1790        })?;
1791
1792        if let Some(ref rev) = supplied_revision {
1793            if rev != &func.revision_id {
1794                return Err(AwsServiceError::aws_error(
1795                    StatusCode::PRECONDITION_FAILED,
1796                    "PreconditionFailedException",
1797                    "The RevisionId provided does not match the latest RevisionId for the Lambda function. Call the GetFunction or the GetAlias API to retrieve the latest RevisionId for your resource.",
1798                ));
1799            }
1800        }
1801        if let Some(ref sha) = supplied_sha {
1802            if sha != &func.code_sha256 {
1803                return Err(AwsServiceError::aws_error(
1804                    StatusCode::PRECONDITION_FAILED,
1805                    "PreconditionFailedException",
1806                    "CodeSha256 does not match the SHA-256 of the function's deployment package.",
1807                ));
1808            }
1809        }
1810
1811        // Pick the next version number per function, monotonic per
1812        // function arn, never reused. AWS uses sequential decimal
1813        // strings starting at 1.
1814        let existing = state
1815            .function_versions
1816            .get(function_name)
1817            .cloned()
1818            .unwrap_or_default();
1819        let latest_version = existing.iter().filter_map(|v| v.parse::<u64>().ok()).max();
1820
1821        // PublishVersion is idempotent on AWS: if `$LATEST` hasn't
1822        // changed since the most recent published version, return that
1823        // existing snapshot instead of bumping the counter. We compare
1824        // every field that PublishVersion would otherwise carry into
1825        // the new snapshot — code identity, description, runtime,
1826        // handler, role, env, memory/timeout, layers, image config,
1827        // VPC, EFS, logging, tracing, kms, ephemeral storage — so a
1828        // config-only change (e.g. UpdateFunctionConfiguration bumping
1829        // memory) still produces a fresh version. This keeps deploy
1830        // pipelines that re-publish on every CI run from leaking a
1831        // fresh numbered version per build when the underlying
1832        // artifact + config are identical.
1833        if let Some(latest_num) = latest_version {
1834            let latest_str = latest_num.to_string();
1835            if let Some(prev_snap) = state
1836                .function_version_snapshots
1837                .get(function_name)
1838                .and_then(|m| m.get(&latest_str))
1839                .cloned()
1840            {
1841                let effective_desc = description_override
1842                    .clone()
1843                    .unwrap_or_else(|| func.description.clone());
1844                if function_config_unchanged_for_publish(&prev_snap, func, &effective_desc) {
1845                    let mut config = self.function_config_json(&prev_snap);
1846                    config["Version"] = json!(latest_str);
1847                    config["FunctionArn"] = json!(format!("{}:{latest_str}", func.function_arn));
1848                    config["MasterArn"] = json!(func.function_arn);
1849                    return Ok(AwsResponse::json(StatusCode::CREATED, config.to_string()));
1850                }
1851            }
1852        }
1853
1854        let next: u64 = latest_version.unwrap_or(0) + 1;
1855        let next_str = next.to_string();
1856
1857        // Snapshot the function config + code for the new immutable version.
1858        let mut snapshot = func.clone();
1859        snapshot.version = next_str.clone();
1860        snapshot.master_arn = Some(func.function_arn.clone());
1861        if let Some(desc) = description_override {
1862            snapshot.description = desc;
1863        }
1864        // Each numbered version gets its own RevisionId, decoupled from $LATEST.
1865        snapshot.revision_id = uuid::Uuid::new_v4().to_string();
1866
1867        // SnapStart optimization completes asynchronously on real Lambda
1868        // when ApplyOn=PublishedVersions. fakecloud has no actual
1869        // optimization step, so we flip OptimizationStatus to "On"
1870        // eagerly on the published-version snapshot so clients that
1871        // wait on this transition see the steady state immediately.
1872        if let Some(snap) = snapshot.snap_start.as_mut() {
1873            if snap.get("ApplyOn").and_then(|v| v.as_str()) == Some("PublishedVersions") {
1874                snap["OptimizationStatus"] = json!("On");
1875            }
1876        }
1877
1878        // Append to numbered list and store the snapshot.
1879        state
1880            .function_versions
1881            .entry(function_name.to_string())
1882            .or_default()
1883            .push(next_str.clone());
1884        state
1885            .function_version_snapshots
1886            .entry(function_name.to_string())
1887            .or_default()
1888            .insert(next_str.clone(), snapshot.clone());
1889
1890        let mut config = self.function_config_json(&snapshot);
1891        config["Version"] = json!(next_str);
1892        config["FunctionArn"] = json!(format!("{}:{next_str}", func.function_arn));
1893        config["MasterArn"] = json!(func.function_arn);
1894
1895        Ok(AwsResponse::json(StatusCode::CREATED, config.to_string()))
1896    }
1897
1898    pub(crate) fn function_config_json(&self, func: &LambdaFunction) -> Value {
1899        // AWS always emits Environment with at least an empty Variables map.
1900        let env_vars = if func.environment.is_empty() {
1901            json!({ "Variables": {} })
1902        } else {
1903            json!({ "Variables": func.environment })
1904        };
1905
1906        let tracing_mode = func.tracing_mode.as_deref().unwrap_or("PassThrough");
1907        let ephemeral_size = func.ephemeral_storage_size.unwrap_or(512);
1908
1909        let mut config = json!({
1910            "FunctionName": func.function_name,
1911            "FunctionArn": func.function_arn,
1912            "Runtime": func.runtime,
1913            "Role": func.role,
1914            "Handler": func.handler,
1915            "Description": func.description,
1916            "Timeout": func.timeout,
1917            "MemorySize": func.memory_size,
1918            "CodeSha256": func.code_sha256,
1919            "CodeSize": func.code_size,
1920            "Version": func.version,
1921            "LastModified": func.last_modified.format("%Y-%m-%dT%H:%M:%S%.3f+0000").to_string(),
1922            "PackageType": func.package_type,
1923            "Architectures": func.architectures,
1924            "Environment": env_vars,
1925            "State": "Active",
1926            "LastUpdateStatus": "Successful",
1927            "TracingConfig": { "Mode": tracing_mode },
1928            "RevisionId": func.revision_id,
1929            "EphemeralStorage": { "Size": ephemeral_size },
1930            "SnapStart": func.snap_start.clone().unwrap_or_else(|| json!({
1931                "ApplyOn": "None",
1932                "OptimizationStatus": "Off",
1933            })),
1934        });
1935        if let Some(ref kms) = func.kms_key_arn {
1936            config["KMSKeyArn"] = json!(kms);
1937        }
1938        if let Some(ref vpc) = func.vpc_config {
1939            config["VpcConfig"] = vpc.clone();
1940        }
1941        if let Some(ref dlq) = func.dead_letter_config_arn {
1942            config["DeadLetterConfig"] = json!({ "TargetArn": dlq });
1943        }
1944        if !func.file_system_configs.is_empty() {
1945            config["FileSystemConfigs"] = json!(func.file_system_configs);
1946        }
1947        if let Some(ref lg) = func.logging_config {
1948            config["LoggingConfig"] = lg.clone();
1949        }
1950        if let Some(ref ic) = func.image_config {
1951            config["ImageConfigResponse"] = json!({ "ImageConfig": ic });
1952        }
1953        if let Some(ref dc) = func.durable_config {
1954            config["DurableConfig"] = dc.clone();
1955        }
1956        if let Some(ref s) = func.signing_profile_version_arn {
1957            config["SigningProfileVersionArn"] = json!(s);
1958        }
1959        if let Some(ref s) = func.signing_job_arn {
1960            config["SigningJobArn"] = json!(s);
1961        }
1962        if let Some(ref rv) = func.runtime_version_config {
1963            config["RuntimeVersionConfig"] = rv.clone();
1964        }
1965        if let Some(ref m) = func.master_arn {
1966            config["MasterArn"] = json!(m);
1967        }
1968        // AWS's `FunctionConfiguration` shape has no `Code` member —
1969        // `FunctionCodeLocation` only appears on `GetFunction`'s response
1970        // wrapper. Image-based functions surface their URI via the
1971        // wrapper at `Code.ImageUri`, set by `get_function`.
1972        if !func.layers.is_empty() {
1973            config["Layers"] = json!(func
1974                .layers
1975                .iter()
1976                .map(|l| json!({"Arn": l.arn, "CodeSize": l.code_size}))
1977                .collect::<Vec<_>>());
1978        }
1979        if let Some(ref r) = func.state_reason {
1980            config["StateReason"] = json!(r);
1981        }
1982        if let Some(ref c) = func.state_reason_code {
1983            config["StateReasonCode"] = json!(c);
1984        }
1985        if let Some(ref r) = func.last_update_status_reason {
1986            config["LastUpdateStatusReason"] = json!(r);
1987        }
1988        if let Some(ref c) = func.last_update_status_reason_code {
1989            config["LastUpdateStatusReasonCode"] = json!(c);
1990        }
1991        config
1992    }
1993}
1994
1995#[async_trait]
1996impl AwsService for LambdaService {
1997    fn service_name(&self) -> &str {
1998        "lambda"
1999    }
2000
2001    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
2002        let (action, resource_name) = Self::resolve_action(&req).ok_or_else(|| {
2003            // Distinguish a genuinely unknown URL path from one that
2004            // hit a known Lambda collection (`/functions`, `/layers`,
2005            // `/event-source-mappings`, `/tags`, `/code-signing-configs`,
2006            // `/account-settings`, `/layers-by-arn`) but couldn't be
2007            // routed because a required identifier was empty or the
2008            // method was wrong. The latter is a client-side validation
2009            // error (`InvalidParameterValueException`), not a
2010            // "service doesn't implement this" signal — collapsing the
2011            // two confuses conformance probes whose synthetic too-short
2012            // identifiers collapse path segments at the URL level.
2013            const KNOWN_COLLECTIONS: &[&str] = &[
2014                "functions",
2015                "layers",
2016                "layers-by-arn",
2017                "event-source-mappings",
2018                "tags",
2019                "account-settings",
2020                "code-signing-configs",
2021            ];
2022            let is_known_collection = req
2023                .path_segments
2024                .get(1)
2025                .map(|s| KNOWN_COLLECTIONS.contains(&s.as_str()))
2026                .unwrap_or(false);
2027            if is_known_collection {
2028                AwsServiceError::aws_error(
2029                    StatusCode::BAD_REQUEST,
2030                    "InvalidParameterValueException",
2031                    format!(
2032                        "Could not route request {} {} — missing or invalid identifier",
2033                        req.method, req.raw_path
2034                    ),
2035                )
2036            } else {
2037                AwsServiceError::aws_error(
2038                    StatusCode::NOT_FOUND,
2039                    "UnknownOperationException",
2040                    format!("Unknown operation: {} {}", req.method, req.raw_path),
2041                )
2042            }
2043        })?;
2044
2045        // Normalize FunctionName-bearing resource slots: AWS Lambda accepts
2046        // bare name, name:qualifier, partial ARN, and full ARN in any URL
2047        // slot that names a function. Layer / event-source-mapping resource
2048        // names go through different routes and are left as-is.
2049        let resource_name = if action_takes_function_name(action) {
2050            // Enforce the Smithy length bound (`FunctionName.length 1..140`)
2051            // before normalization. Synthetic conformance variants drive
2052            // 141-character strings through these paths; without an early
2053            // reject we'd happily serve `GetFunction` against a name that
2054            // could never have been created. The 170-char ceiling tracks
2055            // the documented ARN-form upper bound.
2056            if let Some(raw) = resource_name.as_ref() {
2057                let len = raw.chars().count();
2058                // Bare-name form caps at 140. ARN form
2059                // (`arn:aws:lambda:<region>:<acct>:function:<name>`)
2060                // adds ~60 chars of prefix → up to ~200 total. Reject
2061                // anything longer outright so synthetic 141-char names
2062                // can't bypass the constraint. `InvokeAsync`'s Smithy
2063                // error envelope doesn't declare
2064                // `InvalidParameterValueException`, so route its
2065                // too-long inputs through `ResourceNotFoundException`
2066                // instead — which is declared, and also reflects
2067                // the practical outcome of looking up a 141-char name.
2068                let limit = if raw.starts_with("arn:") { 200 } else { 140 };
2069                if raw.is_empty() || len > limit {
2070                    let (code, msg) = if action == "InvokeAsync" {
2071                        (
2072                            "ResourceNotFoundException",
2073                            format!("Function not found: {}", raw),
2074                        )
2075                    } else {
2076                        (
2077                            "InvalidParameterValueException",
2078                            format!(
2079                                "1 validation error detected: Value '{}' at 'functionName' failed to \
2080                                 satisfy constraint: Member must have length less than or equal to 140",
2081                                raw
2082                            ),
2083                        )
2084                    };
2085                    return Err(AwsServiceError::aws_error(
2086                        if action == "InvokeAsync" {
2087                            StatusCode::NOT_FOUND
2088                        } else {
2089                            StatusCode::BAD_REQUEST
2090                        },
2091                        code,
2092                        msg,
2093                    ));
2094                }
2095            }
2096            resource_name.map(|s| normalize_function_name(&s))
2097        } else {
2098            resource_name
2099        };
2100
2101        // Generic MaxItems range guard. The query is bound to different
2102        // Smithy integer shapes per operation (general `MaxListItems`
2103        // is 1..10000; layer/url/event-invoke/provisioned-concurrency
2104        // listings cap at 50). Pick the right ceiling for the routed
2105        // action so above-max variants trip the validation reliably.
2106        if let Some(raw) = req.query_params.get("MaxItems") {
2107            if let Ok(n) = raw.parse::<i64>() {
2108                let max = match action {
2109                    "ListLayers"
2110                    | "ListLayerVersions"
2111                    | "ListFunctionUrlConfigs"
2112                    | "ListProvisionedConcurrencyConfigs"
2113                    | "ListFunctionEventInvokeConfigs"
2114                    | "ListAliases" => 50,
2115                    _ => 10000,
2116                };
2117                if !(1..=max).contains(&n) {
2118                    return Err(AwsServiceError::aws_error(
2119                        StatusCode::BAD_REQUEST,
2120                        "InvalidParameterValueException",
2121                        format!("MaxItems must be between 1 and {} (got {})", max, n),
2122                    ));
2123                }
2124            }
2125        }
2126
2127        // Smithy `Qualifier` shape is `length 1..128`. Probe variants
2128        // exercise the lower boundary by sending the empty string;
2129        // reject pre-dispatch so every per-handler `parse_qualifier`
2130        // call doesn't need its own check.
2131        if let Some(q) = req.query_params.get("Qualifier") {
2132            let len = q.chars().count();
2133            if q.is_empty() || len > 128 {
2134                return Err(AwsServiceError::aws_error(
2135                    StatusCode::BAD_REQUEST,
2136                    "InvalidParameterValueException",
2137                    format!("Qualifier must be 1..128 characters (got length {})", len),
2138                ));
2139            }
2140        }
2141        // Same guard for the `FunctionVersion` query member used by
2142        // `ListAliases` (`length 1..1024` / pattern `(\\$LATEST|[0-9]+)`).
2143        if let Some(fv) = req.query_params.get("FunctionVersion") {
2144            let len = fv.chars().count();
2145            if fv.is_empty() || len > 1024 {
2146                return Err(AwsServiceError::aws_error(
2147                    StatusCode::BAD_REQUEST,
2148                    "InvalidParameterValueException",
2149                    format!(
2150                        "FunctionVersion must be 1..1024 characters (got length {})",
2151                        len
2152                    ),
2153                ));
2154            }
2155        }
2156
2157        let mutates = matches!(
2158            action,
2159            "CreateFunction"
2160                | "DeleteFunction"
2161                | "PublishVersion"
2162                | "AddPermission"
2163                | "RemovePermission"
2164                | "CreateEventSourceMapping"
2165                | "DeleteEventSourceMapping"
2166                | "UpdateEventSourceMapping"
2167                | "UpdateFunctionCode"
2168                | "UpdateFunctionConfiguration"
2169                | "CreateAlias"
2170                | "DeleteAlias"
2171                | "UpdateAlias"
2172                | "PublishLayerVersion"
2173                | "DeleteLayerVersion"
2174                | "AddLayerVersionPermission"
2175                | "RemoveLayerVersionPermission"
2176                | "CreateFunctionUrlConfig"
2177                | "DeleteFunctionUrlConfig"
2178                | "UpdateFunctionUrlConfig"
2179                | "PutFunctionConcurrency"
2180                | "DeleteFunctionConcurrency"
2181                | "PutProvisionedConcurrencyConfig"
2182                | "DeleteProvisionedConcurrencyConfig"
2183                | "CreateCodeSigningConfig"
2184                | "UpdateCodeSigningConfig"
2185                | "DeleteCodeSigningConfig"
2186                | "PutFunctionCodeSigningConfig"
2187                | "DeleteFunctionCodeSigningConfig"
2188                | "PutFunctionEventInvokeConfig"
2189                | "UpdateFunctionEventInvokeConfig"
2190                | "DeleteFunctionEventInvokeConfig"
2191                | "PutRuntimeManagementConfig"
2192                | "PutFunctionScalingConfig"
2193                | "PutFunctionRecursionConfig"
2194                | "TagResource"
2195                | "UntagResource"
2196                | "InvokeAsync"
2197                | "InvokeWithResponseStream"
2198        );
2199
2200        let aid = &req.account_id;
2201        let result = match action {
2202            "CreateFunction" => self.create_function(&req),
2203            "ListFunctions" => self.list_functions(
2204                aid,
2205                req.query_params.get("FunctionVersion").map(String::as_str),
2206            ),
2207            "GetFunction" => self.get_function(
2208                &req,
2209                resource_name.as_deref().unwrap_or(""),
2210                aid,
2211                req.region.as_str(),
2212                req.query_params.get("Qualifier").map(String::as_str),
2213            ),
2214            "DeleteFunction" => self.delete_function(
2215                resource_name.as_deref().unwrap_or(""),
2216                aid,
2217                req.query_params.get("Qualifier").map(String::as_str),
2218            ),
2219            "Invoke" => {
2220                let invocation_type = InvocationType::from_header(
2221                    req.headers
2222                        .get("x-amz-invocation-type")
2223                        .and_then(|v| v.to_str().ok()),
2224                );
2225                let qualifier = req.query_params.get("Qualifier").map(String::as_str);
2226                self.invoke(
2227                    resource_name.as_deref().unwrap_or(""),
2228                    &req.body,
2229                    aid,
2230                    invocation_type,
2231                    qualifier,
2232                )
2233                .await
2234            }
2235            "InvokeAsync" => {
2236                // `InvokeAsync` is deprecated. AWS returns 202 with a
2237                // `Status` body and never surfaces synchronous-invoke
2238                // errors (`InvalidParameterValueException` isn't in
2239                // the op's declared error envelope). Validate the
2240                // function exists, then enqueue is a no-op.
2241                let name = resource_name.as_deref().unwrap_or("");
2242                let accounts = self.state.read();
2243                let exists = accounts
2244                    .get(aid)
2245                    .map(|s| s.functions.contains_key(name))
2246                    .unwrap_or(false);
2247                if !exists {
2248                    Err(AwsServiceError::aws_error(
2249                        StatusCode::NOT_FOUND,
2250                        "ResourceNotFoundException",
2251                        format!("Function not found: {}", name),
2252                    ))
2253                } else {
2254                    Ok(AwsResponse::json(
2255                        StatusCode::ACCEPTED,
2256                        json!({ "Status": 202 }).to_string(),
2257                    ))
2258                }
2259            }
2260            "PublishVersion" => {
2261                self.publish_version(resource_name.as_deref().unwrap_or(""), aid, &req)
2262            }
2263            "AddPermission" => self.add_permission(resource_name.as_deref().unwrap_or(""), &req),
2264            "GetPolicy" => self.get_policy(
2265                resource_name.as_deref().unwrap_or(""),
2266                aid,
2267                req.query_params.get("Qualifier").map(String::as_str),
2268            ),
2269            "RemovePermission" => {
2270                // Path: /2015-03-31/functions/{name}/policy/{sid}
2271                let sid = req.path_segments.get(4).cloned().unwrap_or_default();
2272                self.remove_permission(
2273                    resource_name.as_deref().unwrap_or(""),
2274                    &sid,
2275                    aid,
2276                    req.query_params.get("Qualifier").map(String::as_str),
2277                )
2278            }
2279            "CreateEventSourceMapping" => self.create_event_source_mapping(&req),
2280            "ListEventSourceMappings" => {
2281                // `FunctionName` is an optional httpQuery member, but
2282                // when present it must satisfy `length 1..140` like
2283                // every other `FunctionName` slot in the API.
2284                if let Some(fn_name) = req.query_params.get("FunctionName") {
2285                    let len = fn_name.chars().count();
2286                    if fn_name.is_empty() || len > 140 {
2287                        return Err(AwsServiceError::aws_error(
2288                            StatusCode::BAD_REQUEST,
2289                            "InvalidParameterValueException",
2290                            "FunctionName must be 1..140 characters",
2291                        ));
2292                    }
2293                }
2294                self.list_event_source_mappings(aid)
2295            }
2296            "GetEventSourceMapping" => {
2297                self.get_event_source_mapping(resource_name.as_deref().unwrap_or(""), aid)
2298            }
2299            "DeleteEventSourceMapping" => {
2300                self.delete_event_source_mapping(resource_name.as_deref().unwrap_or(""), aid)
2301            }
2302            other => {
2303                self.handle_extra(other, resource_name.as_deref(), &req)
2304                    .await
2305            }
2306        };
2307        if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
2308            self.save_snapshot().await;
2309        }
2310        result
2311    }
2312
2313    fn supported_actions(&self) -> &[&str] {
2314        &[
2315            "CreateFunction",
2316            "GetFunction",
2317            "DeleteFunction",
2318            "ListFunctions",
2319            "Invoke",
2320            "InvokeAsync",
2321            "InvokeWithResponseStream",
2322            "PublishVersion",
2323            "ListVersionsByFunction",
2324            "AddPermission",
2325            "RemovePermission",
2326            "GetPolicy",
2327            "CreateEventSourceMapping",
2328            "ListEventSourceMappings",
2329            "GetEventSourceMapping",
2330            "UpdateEventSourceMapping",
2331            "DeleteEventSourceMapping",
2332            "GetFunctionConfiguration",
2333            "UpdateFunctionConfiguration",
2334            "UpdateFunctionCode",
2335            "GetAccountSettings",
2336            "CreateAlias",
2337            "GetAlias",
2338            "ListAliases",
2339            "UpdateAlias",
2340            "DeleteAlias",
2341            "PublishLayerVersion",
2342            "GetLayerVersion",
2343            "GetLayerVersionByArn",
2344            "DeleteLayerVersion",
2345            "ListLayerVersions",
2346            "ListLayers",
2347            "GetLayerVersionPolicy",
2348            "AddLayerVersionPermission",
2349            "RemoveLayerVersionPermission",
2350            "CreateFunctionUrlConfig",
2351            "GetFunctionUrlConfig",
2352            "UpdateFunctionUrlConfig",
2353            "DeleteFunctionUrlConfig",
2354            "ListFunctionUrlConfigs",
2355            "PutFunctionConcurrency",
2356            "GetFunctionConcurrency",
2357            "DeleteFunctionConcurrency",
2358            "PutProvisionedConcurrencyConfig",
2359            "GetProvisionedConcurrencyConfig",
2360            "DeleteProvisionedConcurrencyConfig",
2361            "ListProvisionedConcurrencyConfigs",
2362            "CreateCodeSigningConfig",
2363            "GetCodeSigningConfig",
2364            "UpdateCodeSigningConfig",
2365            "DeleteCodeSigningConfig",
2366            "ListCodeSigningConfigs",
2367            "PutFunctionCodeSigningConfig",
2368            "GetFunctionCodeSigningConfig",
2369            "DeleteFunctionCodeSigningConfig",
2370            "ListFunctionsByCodeSigningConfig",
2371            "PutFunctionEventInvokeConfig",
2372            "GetFunctionEventInvokeConfig",
2373            "UpdateFunctionEventInvokeConfig",
2374            "DeleteFunctionEventInvokeConfig",
2375            "ListFunctionEventInvokeConfigs",
2376            "PutRuntimeManagementConfig",
2377            "GetRuntimeManagementConfig",
2378            "PutFunctionScalingConfig",
2379            "GetFunctionScalingConfig",
2380            "PutFunctionRecursionConfig",
2381            "GetFunctionRecursionConfig",
2382            "TagResource",
2383            "UntagResource",
2384            "ListTags",
2385        ]
2386    }
2387
2388    fn iam_enforceable(&self) -> bool {
2389        true
2390    }
2391
2392    /// Lambda resources are function ARNs. Function-scoped ops
2393    /// resolve the target ARN from the path; list ops target `*`
2394    /// (the whole service), matching how AWS models them.
2395    fn iam_action_for(&self, request: &AwsRequest) -> Option<fakecloud_core::auth::IamAction> {
2396        // REST-JSON services don't have `request.action` populated at
2397        // dispatch time — it's derived from method+path inside
2398        // `handle()`. Reuse the same resolver so the two can never
2399        // drift.
2400        let (action_str, resource_name) = Self::resolve_action(request)?;
2401        let action: &'static str = match action_str {
2402            "CreateFunction" => "CreateFunction",
2403            "ListFunctions" => "ListFunctions",
2404            "GetFunction" => "GetFunction",
2405            "DeleteFunction" => "DeleteFunction",
2406            "Invoke" => "InvokeFunction",
2407            "InvokeWithResponseStream" => "InvokeFunctionWithResponseStream",
2408            "PublishVersion" => "PublishVersion",
2409            "AddPermission" => "AddPermission",
2410            "RemovePermission" => "RemovePermission",
2411            "GetPolicy" => "GetPolicy",
2412            "CreateEventSourceMapping" => "CreateEventSourceMapping",
2413            "ListEventSourceMappings" => "ListEventSourceMappings",
2414            "GetEventSourceMapping" => "GetEventSourceMapping",
2415            "DeleteEventSourceMapping" => "DeleteEventSourceMapping",
2416            _ => return None,
2417        };
2418        let accounts = self.state.read();
2419        let empty = LambdaState::new(&request.account_id, &request.region);
2420        let state = accounts.get(&request.account_id).unwrap_or(&empty);
2421        let resource = match action {
2422            "GetFunction"
2423            | "DeleteFunction"
2424            | "InvokeFunction"
2425            | "InvokeFunctionWithResponseStream"
2426            | "PublishVersion"
2427            | "AddPermission"
2428            | "RemovePermission"
2429            | "GetPolicy" => {
2430                let name = resource_name.unwrap_or_default();
2431                if name.is_empty() {
2432                    "*".to_string()
2433                } else {
2434                    format!(
2435                        "arn:aws:lambda:{}:{}:function:{}",
2436                        state.region, state.account_id, name
2437                    )
2438                }
2439            }
2440            "CreateFunction" => {
2441                // Best-effort: parse the FunctionName from the body so
2442                // CreateFunction can be resource-scoped against the
2443                // to-be-created ARN. Falls back to `*` when the body
2444                // isn't JSON yet (e.g. soft-mode observability).
2445                serde_json::from_slice::<Value>(&request.body)
2446                    .ok()
2447                    .and_then(|v| {
2448                        v.get("FunctionName").and_then(|f| f.as_str()).map(|n| {
2449                            format!(
2450                                "arn:aws:lambda:{}:{}:function:{}",
2451                                state.region, state.account_id, n
2452                            )
2453                        })
2454                    })
2455                    .unwrap_or_else(|| "*".to_string())
2456            }
2457            _ => "*".to_string(),
2458        };
2459        Some(fakecloud_core::auth::IamAction {
2460            service: "lambda",
2461            action,
2462            resource,
2463        })
2464    }
2465
2466    fn iam_condition_keys_for(
2467        &self,
2468        request: &AwsRequest,
2469        action: &fakecloud_core::auth::IamAction,
2470    ) -> std::collections::BTreeMap<String, Vec<String>> {
2471        let mut out = std::collections::BTreeMap::new();
2472        if action.action == "AddPermission" {
2473            if action.resource != "*" {
2474                out.insert(
2475                    "lambda:functionarn".to_string(),
2476                    vec![action.resource.clone()],
2477                );
2478            }
2479            if let Ok(body) = serde_json::from_slice::<Value>(&request.body) {
2480                if let Some(principal) = body.get("Principal").and_then(|p| p.as_str()) {
2481                    out.insert("lambda:principal".to_string(), vec![principal.to_string()]);
2482                }
2483            }
2484        }
2485        out
2486    }
2487}
2488
2489#[path = "service_event_sources.rs"]
2490mod service_event_sources;
2491#[path = "service_permissions.rs"]
2492mod service_permissions;
2493
2494#[cfg(test)]
2495#[path = "service_tests.rs"]
2496mod tests;