Skip to main content

fakecloud_lambda/extras/
mod.rs

1//! Lambda handlers added to close the conformance gap. Aliases, layers,
2//! function URL configs, concurrency, code signing, event invoke, runtime
3//! management, scaling, recursion, tagging, and account settings.
4
5use chrono::Utc;
6use http::StatusCode;
7use serde_json::{json, Value};
8use sha2::{Digest, Sha256};
9
10use fakecloud_aws::arn::Arn;
11use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
12
13use crate::service::LambdaService;
14use crate::state::{
15    AccountSettings, AttachedLayer, CodeSigningConfig, EventInvokeConfig, FunctionAlias,
16    FunctionScalingConfig, FunctionUrlConfig, LambdaState, Layer, LayerVersion,
17    ProvisionedConcurrencyConfig, RuntimeManagementConfig,
18};
19
20/// Resolve a layer-version ARN to its current `CodeSize` from the
21/// multi-account state. Returns 0 when the ARN is unparseable, when the
22/// referenced account/layer/version is unknown, or when the version was
23/// published without ZIP content (legacy snapshots).
24pub(crate) fn resolve_layer_attachments(
25    accounts: &fakecloud_core::multi_account::MultiAccountState<LambdaState>,
26    arns: Vec<String>,
27) -> Vec<AttachedLayer> {
28    arns.into_iter()
29        .map(|arn| {
30            let code_size = parse_layer_version_arn(&arn)
31                .and_then(|(acct, name, ver)| {
32                    accounts
33                        .get(&acct)
34                        .and_then(|s| s.layers.get(&name))
35                        .and_then(|l| l.versions.iter().find(|v| v.version == ver))
36                        .map(|v| v.code_size)
37                })
38                .unwrap_or(0);
39            AttachedLayer { arn, code_size }
40        })
41        .collect()
42}
43
44fn missing(name: &str) -> AwsServiceError {
45    AwsServiceError::aws_error(
46        StatusCode::BAD_REQUEST,
47        "InvalidParameterValueException",
48        format!("Missing required field: {name}"),
49    )
50}
51
52fn not_found(entity: &str, name: &str) -> AwsServiceError {
53    AwsServiceError::aws_error(
54        StatusCode::NOT_FOUND,
55        "ResourceNotFoundException",
56        format!("{entity} not found: {name}"),
57    )
58}
59
60fn ok(body: Value) -> Result<AwsResponse, AwsServiceError> {
61    Ok(AwsResponse::json(StatusCode::OK, body.to_string()))
62}
63
64fn empty() -> Result<AwsResponse, AwsServiceError> {
65    Ok(AwsResponse::json(StatusCode::OK, "{}".to_string()))
66}
67
68fn body(req: &AwsRequest) -> Value {
69    serde_json::from_slice(&req.body).unwrap_or_else(|_| Value::Object(Default::default()))
70}
71
72/// Extract the function name from a Lambda function ARN, ignoring any
73/// trailing `:version` / `:alias` qualifier. Returns `None` for ARNs
74/// that name a different resource type (event-source mapping,
75/// code-signing config, layer, …) — Lambda only supports tags on
76/// function ARNs in this implementation, so non-function ARNs are
77/// rejected by callers as `InvalidParameterValueException`.
78fn function_name_from_arn(arn: &str) -> Option<String> {
79    let rest = arn.strip_prefix("arn:aws:lambda:")?;
80    let mut parts = rest.splitn(5, ':');
81    let _region = parts.next()?;
82    let _account = parts.next()?;
83    let resource_kind = parts.next()?;
84    if resource_kind != "function" {
85        return None;
86    }
87    let name_with_qualifier = parts.next()?;
88    Some(
89        name_with_qualifier
90            .split(':')
91            .next()
92            .unwrap_or(name_with_qualifier)
93            .to_string(),
94    )
95}
96
97/// Parse a raw query string into key/value pairs preserving repeats.
98/// `req.query_params` is a `HashMap<String, String>` and so collapses
99/// `tagKeys=A&tagKeys=B` to a single entry; this lets the
100/// `UntagResource` handler see every value the caller actually sent.
101/// Percent-decodes both key and value with the same lossy fallback the
102/// rest of the dispatch path uses.
103fn parse_query_pairs(raw_query: &str) -> Vec<(String, String)> {
104    raw_query
105        .split('&')
106        .filter(|s| !s.is_empty())
107        .map(|pair| {
108            let mut it = pair.splitn(2, '=');
109            let k = it.next().unwrap_or("");
110            let v = it.next().unwrap_or("");
111            (decode_query_segment(k), decode_query_segment(v))
112        })
113        .collect()
114}
115
116fn decode_query_segment(s: &str) -> String {
117    // Replace `+` with space to match `application/x-www-form-urlencoded`,
118    // then percent-decode. SDKs hit both shapes for path/query data.
119    let plus_decoded = s.replace('+', " ");
120    percent_encoding::percent_decode_str(&plus_decoded)
121        .decode_utf8_lossy()
122        .into_owned()
123}
124
125/// Build a fakecloud-hosted download URL for a layer version's ZIP. The URL
126/// is reachable on the same authority the SDK used for the original
127/// request, so test harnesses get a working `Location` they can `GET`
128/// directly instead of the placeholder AWS clients otherwise see.
129fn layer_content_url(req: &AwsRequest, account_id: &str, layer_name: &str, version: i64) -> String {
130    let host = req
131        .headers
132        .get(http::header::HOST)
133        .and_then(|h| h.to_str().ok())
134        .unwrap_or("localhost");
135    let scheme = req
136        .headers
137        .get("x-forwarded-proto")
138        .and_then(|h| h.to_str().ok())
139        .unwrap_or("http");
140    format!(
141        "{scheme}://{host}/_fakecloud/lambda/layer-content/{account_id}/{layer_name}/{version}.zip"
142    )
143}
144
145/// Build a fakecloud-hosted download URL for a function version's ZIP. AWS
146/// Toolkit (and `aws lambda get-function --query 'Code.Location'`) expects
147/// this to resolve to an actual ZIP body, so the URL points back at the
148/// running fakecloud instance on the same authority the SDK used.
149pub(crate) fn function_code_url(
150    req: &AwsRequest,
151    account_id: &str,
152    function_name: &str,
153    version_label: &str,
154) -> String {
155    let host = req
156        .headers
157        .get(http::header::HOST)
158        .and_then(|h| h.to_str().ok())
159        .unwrap_or("localhost");
160    let scheme = req
161        .headers
162        .get("x-forwarded-proto")
163        .and_then(|h| h.to_str().ok())
164        .unwrap_or("http");
165    let file = if version_label == "$LATEST" {
166        "latest.zip".to_string()
167    } else {
168        format!("{version_label}.zip")
169    };
170    format!("{scheme}://{host}/_fakecloud/lambda/function-code/{account_id}/{function_name}/{file}")
171}
172
173/// AWS layer-version ARN: `arn:aws:lambda:<region>:<account>:layer:<name>:<version>`.
174/// Returns `(account_id, layer_name, version)`. Used to resolve cross-account
175/// layer references attached to a function.
176pub fn parse_layer_version_arn(arn: &str) -> Option<(String, String, i64)> {
177    let parts: Vec<&str> = arn.split(':').collect();
178    if parts.len() != 8 || parts[0] != "arn" || parts[2] != "lambda" || parts[5] != "layer" {
179        return None;
180    }
181    let account = parts[4].to_string();
182    let name = parts[6].to_string();
183    let version: i64 = parts[7].parse().ok()?;
184    Some((account, name, version))
185}
186
187/// Enum members of `com.amazonaws.lambda#Runtime`. Used by layer-listing
188/// ops to validate the `CompatibleRuntime` query filter without
189/// teaching every handler the full enum.
190const LAMBDA_RUNTIMES: &[&str] = &[
191    "nodejs",
192    "nodejs4.3",
193    "nodejs6.10",
194    "nodejs8.10",
195    "nodejs10.x",
196    "nodejs12.x",
197    "nodejs14.x",
198    "nodejs16.x",
199    "nodejs18.x",
200    "nodejs20.x",
201    "nodejs22.x",
202    "nodejs24.x",
203    "nodejs4.3-edge",
204    "java8",
205    "java8.al2",
206    "java11",
207    "java17",
208    "java21",
209    "java25",
210    "python2.7",
211    "python3.6",
212    "python3.7",
213    "python3.8",
214    "python3.9",
215    "python3.10",
216    "python3.11",
217    "python3.12",
218    "python3.13",
219    "python3.14",
220    "dotnetcore1.0",
221    "dotnetcore2.0",
222    "dotnetcore2.1",
223    "dotnetcore3.1",
224    "dotnet6",
225    "dotnet8",
226    "dotnet10",
227    "go1.x",
228    "ruby2.5",
229    "ruby2.7",
230    "ruby3.2",
231    "ruby3.3",
232    "ruby3.4",
233    "provided",
234    "provided.al2",
235    "provided.al2023",
236];
237
238/// Validate the `CompatibleArchitecture` and `CompatibleRuntime` query
239/// filters shared by `ListLayers` and `ListLayerVersions`.
240fn validate_layer_filters(req: &AwsRequest) -> Result<(), AwsServiceError> {
241    if let Some(arch) = req.query_params.get("CompatibleArchitecture") {
242        if arch != "x86_64" && arch != "arm64" {
243            return Err(AwsServiceError::aws_error(
244                StatusCode::BAD_REQUEST,
245                "InvalidParameterValueException",
246                format!(
247                    "Invalid CompatibleArchitecture value '{}'; expected 'x86_64' or 'arm64'",
248                    arch
249                ),
250            ));
251        }
252    }
253    if let Some(rt) = req.query_params.get("CompatibleRuntime") {
254        if !LAMBDA_RUNTIMES.contains(&rt.as_str()) {
255            return Err(AwsServiceError::aws_error(
256                StatusCode::BAD_REQUEST,
257                "InvalidParameterValueException",
258                format!("Invalid CompatibleRuntime value '{}'", rt),
259            ));
260        }
261    }
262    Ok(())
263}
264
265fn parse_qualifier(req: &AwsRequest) -> String {
266    req.query_params
267        .get("Qualifier")
268        .cloned()
269        .unwrap_or_else(|| "$LATEST".to_string())
270}
271
272/// Strict variant for operations whose Smithy model marks `Qualifier`
273/// `@required` (provisioned-concurrency, scaling-config). Returns
274/// `InvalidParameterValueException` when the query parameter is
275/// missing, matching AWS's wire response.
276fn require_qualifier(req: &AwsRequest) -> Result<String, AwsServiceError> {
277    req.query_params.get("Qualifier").cloned().ok_or_else(|| {
278        AwsServiceError::aws_error(
279            StatusCode::BAD_REQUEST,
280            "InvalidParameterValueException",
281            "Qualifier is required for this operation",
282        )
283    })
284}
285
286fn id_from_time(prefix: &str) -> String {
287    format!(
288        "{}{}",
289        prefix,
290        std::time::SystemTime::now()
291            .duration_since(std::time::UNIX_EPOCH)
292            .map(|d| d.as_nanos())
293            .unwrap_or(0)
294    )
295}
296
297mod account;
298mod aliases;
299mod code_signing;
300mod concurrency;
301mod event_invoke;
302mod function_url;
303mod layers;
304mod recursion;
305mod runtime;
306mod stream;
307
308impl LambdaService {
309    fn with_state_read<F, R>(&self, account_id: &str, region: &str, f: F) -> R
310    where
311        F: FnOnce(&LambdaState) -> R,
312    {
313        let accounts = self.state.read();
314        let empty = LambdaState::new(account_id, region);
315        let state = accounts.get(account_id).unwrap_or(&empty);
316        f(state)
317    }
318
319    // ── Function lifecycle extras ──
320
321    fn get_function_configuration(
322        &self,
323        function_name: &str,
324        account_id: &str,
325        req: &AwsRequest,
326    ) -> Result<AwsResponse, AwsServiceError> {
327        let region = self.region_for(account_id);
328        let qualifier = req.query_params.get("Qualifier").cloned();
329        self.with_state_read(account_id, &region, |state| {
330            let live = state
331                .functions
332                .get(function_name)
333                .ok_or_else(|| not_found("Function", function_name))?;
334            // Qualifier resolution mirrors GetFunction: $LATEST or omitted
335            // returns the live config; numeric / alias qualifiers resolve
336            // to a numbered snapshot.
337            let resolved = crate::service::resolve_qualifier_to_version(
338                state,
339                function_name,
340                qualifier.as_deref(),
341            );
342            let (func, version_label) = match resolved {
343                None => (live, "$LATEST".to_string()),
344                Some(v) => {
345                    let snap = state
346                        .function_version_snapshots
347                        .get(function_name)
348                        .and_then(|m| m.get(&v))
349                        .ok_or_else(|| not_found("Function", function_name))?;
350                    (snap, v)
351                }
352            };
353            let mut config = self.function_config_json(func);
354            config["Version"] = json!(version_label);
355            if version_label != "$LATEST" {
356                config["FunctionArn"] = json!(format!("{}:{version_label}", live.function_arn));
357                config["MasterArn"] = json!(live.function_arn);
358            }
359            ok(config)
360        })
361    }
362
363    fn update_function_configuration(
364        &self,
365        function_name: &str,
366        req: &AwsRequest,
367    ) -> Result<AwsResponse, AwsServiceError> {
368        let body = body(req);
369        // Validate before taking the write lock and before any mutation:
370        // an invalid EphemeralStorage.Size on an otherwise valid request
371        // must not silently apply the surrounding fields.
372        let validated_ephemeral = match body["EphemeralStorage"]["Size"].as_i64() {
373            Some(size) => Some(crate::service::validate_ephemeral_storage(size)?),
374            None => None,
375        };
376        let mut accounts = self.state.write();
377        // Pre-resolve layer attachments before re-borrowing accounts mutably
378        // for the function. Layer ARNs may live in sibling accounts.
379        let layer_attachments: Option<Vec<AttachedLayer>> = body["Layers"].as_array().map(|arr| {
380            let arns: Vec<String> = arr
381                .iter()
382                .filter_map(|v| v.as_str().map(String::from))
383                .collect();
384            resolve_layer_attachments(&accounts, arns)
385        });
386        let state = accounts.get_or_create(&req.account_id);
387        let func = state
388            .functions
389            .get_mut(function_name)
390            .ok_or_else(|| not_found("Function", function_name))?;
391        if let Some(handler) = body["Handler"].as_str() {
392            func.handler = handler.to_string();
393        }
394        if let Some(t) = body["Timeout"].as_i64() {
395            func.timeout = t;
396        }
397        if let Some(m) = body["MemorySize"].as_i64() {
398            func.memory_size = m;
399        }
400        if let Some(role) = body["Role"].as_str() {
401            func.role = role.to_string();
402        }
403        if let Some(desc) = body["Description"].as_str() {
404            func.description = desc.to_string();
405        }
406        if let Some(rt) = body["Runtime"].as_str() {
407            func.runtime = rt.to_string();
408        }
409        if let Some(env) = body["Environment"]["Variables"].as_object() {
410            func.environment = env
411                .iter()
412                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
413                .collect();
414        }
415        if let Some(mode) = body["TracingConfig"]["Mode"].as_str() {
416            func.tracing_mode = Some(mode.to_string());
417        }
418        if let Some(arn) = body["KMSKeyArn"].as_str() {
419            func.kms_key_arn = if arn.is_empty() {
420                None
421            } else {
422                Some(arn.to_string())
423            };
424        }
425        if let Some(size) = validated_ephemeral {
426            func.ephemeral_storage_size = Some(size);
427        }
428        if body["VpcConfig"].is_object() {
429            func.vpc_config = Some(body["VpcConfig"].clone());
430        }
431        if body["SnapStart"].is_object() {
432            func.snap_start = Some(body["SnapStart"].clone());
433        }
434        if let Some(arn) = body["DeadLetterConfig"]["TargetArn"].as_str() {
435            func.dead_letter_config_arn = if arn.is_empty() {
436                None
437            } else {
438                Some(arn.to_string())
439            };
440        }
441        if let Some(fsc) = body["FileSystemConfigs"].as_array() {
442            func.file_system_configs = fsc.clone();
443        }
444        if body["LoggingConfig"].is_object() {
445            func.logging_config = Some(body["LoggingConfig"].clone());
446        }
447        if body["ImageConfig"].is_object() {
448            func.image_config = Some(body["ImageConfig"].clone());
449        }
450        if body["DurableConfig"].is_object() {
451            func.durable_config = Some(body["DurableConfig"].clone());
452        }
453        if let Some(attachments) = layer_attachments {
454            func.layers = attachments;
455        }
456        // RevisionId rotates only on real config changes — clients
457        // round-trip it through optimistic-concurrency calls, so we
458        // mint a fresh one here to signal "config changed".
459        func.revision_id = uuid::Uuid::new_v4().to_string();
460        func.last_modified = Utc::now();
461        ok(self.function_config_json(func))
462    }
463
464    fn update_function_code(
465        &self,
466        function_name: &str,
467        req: &AwsRequest,
468    ) -> Result<AwsResponse, AwsServiceError> {
469        let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap_or_default();
470
471        // ZipFile / ImageUri / S3Bucket+S3Key are mutually exclusive; AWS
472        // rejects the request when more than one is present. The handler
473        // picks one with a defined precedence: ZipFile, S3 descriptor,
474        // ImageUri.
475        let new_zip: Option<Vec<u8>> = match body["ZipFile"].as_str() {
476            Some(b64) => Some(
477                base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).map_err(
478                    |_| {
479                        AwsServiceError::aws_error(
480                            StatusCode::BAD_REQUEST,
481                            "InvalidParameterValueException",
482                            "Could not decode ZipFile: invalid base64",
483                        )
484                    },
485                )?,
486            ),
487            None => None,
488        };
489        let new_image_uri = body["ImageUri"].as_str().map(String::from);
490        // S3 source descriptor: when the caller didn't supply ZipFile or
491        // ImageUri, AWS expects S3Bucket+S3Key (S3ObjectVersion is
492        // optional). fakecloud doesn't fetch the object — CreateFunction
493        // takes the same shortcut — so we synthesize a fingerprint from
494        // the descriptor and use that as the new code identity. The hash
495        // and size still rotate when the descriptor differs, so
496        // optimistic-concurrency callers see RevisionId bump on real
497        // changes.
498        // S3-sourced code: if an S3Delivery hook is wired, fetch the
499        // actual object bytes and treat them as a ZIP upload. This
500        // matches real Lambda's S3-pull semantics. Fall back to the
501        // descriptor-hash shortcut when no hook is available.
502        let s3_fetched_zip: Option<Vec<u8>> = match (
503            body["S3Bucket"].as_str(),
504            body["S3Key"].as_str(),
505        ) {
506            (Some(bucket), Some(key)) if new_zip.is_none() && new_image_uri.is_none() => {
507                if let Some(s3) = &self.s3_delivery {
508                    match s3.get_object(&req.account_id, bucket, key) {
509                        Ok(bytes) => Some(bytes),
510                        Err(e) => {
511                            return Err(AwsServiceError::aws_error(
512                                StatusCode::BAD_REQUEST,
513                                "InvalidParameterValueException",
514                                format!("Error occurred while GetObject. S3 Error Code: NoSuchKey. S3 Error Message: {e}"),
515                            ));
516                        }
517                    }
518                } else {
519                    None
520                }
521            }
522            _ => None,
523        };
524
525        let new_s3_descriptor: Option<Vec<u8>> =
526            match (body["S3Bucket"].as_str(), body["S3Key"].as_str()) {
527                (Some(bucket), Some(key))
528                    if new_zip.is_none() && new_image_uri.is_none() && s3_fetched_zip.is_none() =>
529                {
530                    let mut descriptor = serde_json::Map::new();
531                    descriptor.insert("S3Bucket".to_string(), Value::String(bucket.to_string()));
532                    descriptor.insert("S3Key".to_string(), Value::String(key.to_string()));
533                    if let Some(ver) = body["S3ObjectVersion"].as_str() {
534                        descriptor.insert(
535                            "S3ObjectVersion".to_string(),
536                            Value::String(ver.to_string()),
537                        );
538                    }
539                    Some(serde_json::to_vec(&Value::Object(descriptor)).unwrap_or_default())
540                }
541                _ => None,
542            };
543        let new_zip = new_zip.or(s3_fetched_zip);
544        let supplied_signing_profile = body["SigningProfileVersionArn"].as_str().map(String::from);
545        let supplied_revision_id = body["RevisionId"].as_str().map(String::from);
546        let new_architectures: Option<Vec<String>> = body["Architectures"].as_array().map(|arr| {
547            arr.iter()
548                .filter_map(|v| v.as_str().map(String::from))
549                .collect()
550        });
551        let dry_run = body["DryRun"].as_bool().unwrap_or(false);
552        let publish = body["Publish"].as_bool().unwrap_or(false);
553
554        let mut accounts = self.state.write();
555        let state = accounts.get_or_create(&req.account_id);
556
557        // Function existence is the first check so callers always see
558        // ResourceNotFoundException 404 even when CSC / sig-profile
559        // fields would otherwise reject the request.
560        if !state.functions.contains_key(function_name) {
561            return Err(not_found("Function", function_name));
562        }
563
564        // Code-signing gate: if a CSC is bound to this function and at
565        // least one allowed publisher is registered, the caller must
566        // supply a SigningProfileVersionArn from that allow-list when
567        // the policy is Enforce. Warn just lets the upload through.
568        if let Some(csc_arn) = state.function_code_signing.get(function_name).cloned() {
569            let csc_id = extract_csc_id(&csc_arn);
570            if let Some(csc) = state.code_signing_configs.get(&csc_id).cloned() {
571                if !csc.allowed_publishers.is_empty()
572                    && csc
573                        .untrusted_artifact_action
574                        .eq_ignore_ascii_case("Enforce")
575                {
576                    let allowed = match supplied_signing_profile.as_deref() {
577                        Some(arn) => csc.allowed_publishers.iter().any(|p| p == arn),
578                        None => false,
579                    };
580                    if !allowed {
581                        return Err(AwsServiceError::aws_error(
582                            StatusCode::BAD_REQUEST,
583                            "CodeVerificationFailedException",
584                            "The code signature failed the integrity check or the signing profile is not in the allowed publishers list.",
585                        ));
586                    }
587                }
588            }
589        }
590
591        let func = state
592            .functions
593            .get_mut(function_name)
594            .ok_or_else(|| not_found("Function", function_name))?;
595
596        // Optimistic-concurrency precondition: when the caller supplies
597        // a RevisionId, it must match the function's current revision
598        // or AWS rejects with PreconditionFailedException 412.
599        if let Some(ref rev) = supplied_revision_id {
600            if rev != &func.revision_id {
601                return Err(AwsServiceError::aws_error(
602                    StatusCode::PRECONDITION_FAILED,
603                    "PreconditionFailedException",
604                    format!(
605                        "The Revision Id provided: {rev} does not match the latest Revision Id of function: {function_name}. Call the GetFunction/GetAlias API to retrieve the latest Revision Id"
606                    ),
607                ));
608            }
609        }
610
611        // DryRun validates the request shape but never mutates state.
612        if dry_run {
613            return ok(self.function_config_json(func));
614        }
615
616        let mut changed = false;
617        if let Some(bytes) = new_zip {
618            // SHA256(base64) of the new code, matching CreateFunction's
619            // hash so GetFunction returns identical CodeSha256 round-trip.
620            let mut hasher = Sha256::new();
621            hasher.update(&bytes);
622            let hash = hasher.finalize();
623            let code_sha256 =
624                base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash);
625            if code_sha256 != func.code_sha256 {
626                changed = true;
627            }
628            func.code_size = bytes.len() as i64;
629            func.code_zip = Some(bytes);
630            func.code_sha256 = code_sha256;
631            func.image_uri = None;
632            func.package_type = "Zip".to_string();
633        } else if let Some(descriptor_bytes) = new_s3_descriptor {
634            // Hash the S3 descriptor JSON (S3Bucket+S3Key+optional
635            // S3ObjectVersion) so the same descriptor produces a stable
636            // sha and a different descriptor rotates RevisionId. This
637            // mirrors CreateFunction's behavior for S3-sourced code,
638            // which also fingerprints the descriptor rather than fetching
639            // S3 (real Lambda fetches asynchronously).
640            let mut hasher = Sha256::new();
641            hasher.update(&descriptor_bytes);
642            let hash = hasher.finalize();
643            let code_sha256 =
644                base64::Engine::encode(&base64::engine::general_purpose::STANDARD, hash);
645            if code_sha256 != func.code_sha256 {
646                changed = true;
647            }
648            func.code_size = descriptor_bytes.len() as i64;
649            // We don't have the object bytes — clear the cached zip so
650            // the runtime falls back to whatever it had previously cached
651            // rather than serving stale bytes for the new descriptor.
652            func.code_zip = None;
653            func.code_sha256 = code_sha256;
654            func.image_uri = None;
655            func.package_type = "Zip".to_string();
656        } else if let Some(uri) = new_image_uri {
657            if func.image_uri.as_deref() != Some(uri.as_str()) {
658                changed = true;
659            }
660            func.image_uri = Some(uri);
661            func.code_zip = None;
662            func.package_type = "Image".to_string();
663            // AWS reports CodeSize=0 and an empty CodeSha256 for
664            // image-package functions — the actual digest lives on the
665            // ECR side, not in the Lambda response.
666            func.code_size = 0;
667            func.code_sha256 = String::new();
668        }
669
670        if let Some(arns) = new_architectures {
671            if !arns.is_empty() && arns != func.architectures {
672                changed = true;
673                func.architectures = arns;
674            }
675        }
676
677        if let Some(arn) = supplied_signing_profile {
678            if func.signing_profile_version_arn.as_deref() != Some(arn.as_str()) {
679                changed = true;
680            }
681            func.signing_profile_version_arn = Some(arn);
682        }
683
684        // last_modified is bumped on every call (matches AWS), but
685        // revision_id only rotates when code or signing fields actually
686        // change so optimistic-concurrency callers don't see spurious
687        // updates from no-op pings.
688        func.last_modified = Utc::now();
689        if changed {
690            func.revision_id = uuid::Uuid::new_v4().to_string();
691        }
692        // A successful UpdateFunctionCode clears any prior failure
693        // reason — function_config_json elides the field when None,
694        // matching AWS's "no LastUpdateStatusReason on success" shape.
695        func.last_update_status_reason = None;
696        func.last_update_status_reason_code = None;
697
698        // Publish=true mints a new immutable version snapshot off the
699        // freshly updated $LATEST and returns that version's config.
700        if publish {
701            drop(accounts);
702            return self.publish_version(function_name, &req.account_id, req);
703        }
704
705        ok(self.function_config_json(func))
706    }
707
708    // ── Versions ──
709
710    fn list_versions_by_function(
711        &self,
712        function_name: &str,
713        account_id: &str,
714        req: &AwsRequest,
715    ) -> Result<AwsResponse, AwsServiceError> {
716        let region = self.region_for(account_id);
717        // ListVersionsByFunction MaxItems range is 1..10000 in the Smithy model
718        // (default 50), not capped at 50.
719        let max_items: usize = req
720            .query_params
721            .get("MaxItems")
722            .and_then(|v| v.parse::<usize>().ok())
723            .map(|n| n.clamp(1, 10000))
724            .unwrap_or(50);
725        let marker = req.query_params.get("Marker").cloned();
726        self.with_state_read(account_id, &region, |state| {
727            let func = state
728                .functions
729                .get(function_name)
730                .ok_or_else(|| not_found("Function", function_name))?;
731            // AWS returns $LATEST first, then numbered versions in
732            // ascending order. Each numbered version is an immutable
733            // snapshot of the function at publish time.
734            let mut all: Vec<serde_json::Value> = Vec::new();
735            let mut latest = self.function_config_json(func);
736            latest["Version"] = json!("$LATEST");
737            // In ListVersionsByFunction, AWS qualifies the $LATEST entry's
738            // FunctionArn with `:$LATEST` (unlike a bare GetFunction). The
739            // Terraform `aws_lambda_function` resource derives `qualified_arn`
740            // from this entry, so it must carry the qualifier.
741            latest["FunctionArn"] = json!(format!("{}:$LATEST", func.function_arn));
742            all.push(latest);
743            let snapshots = state.function_version_snapshots.get(function_name);
744            if let Some(numbered) = state.function_versions.get(function_name) {
745                for v in numbered {
746                    let snap = snapshots.and_then(|m| m.get(v)).unwrap_or(func);
747                    let mut cfg = self.function_config_json(snap);
748                    cfg["Version"] = json!(v);
749                    cfg["FunctionArn"] = json!(format!("{}:{v}", func.function_arn));
750                    cfg["MasterArn"] = json!(func.function_arn);
751                    all.push(cfg);
752                }
753            }
754            // Pagination: skip past Marker if supplied (Marker is the
755            // Version string of the entry to start *after*), then take
756            // up to MaxItems. Emit a NextMarker when truncated.
757            //
758            // Resume at the first entry strictly after the marker in the list's
759            // ($LATEST-first, then ascending-numeric) order. A version can be
760            // deleted between pages, so an exact-match lookup would fall back to 0
761            // and re-emit $LATEST; comparing on the decoded (is-numbered, number)
762            // rank advances correctly instead. Lexical string order is wrong here
763            // ("10" < "2"), so we must decode the numeric version.
764            let version_rank = |v: &str| -> (u8, u64) {
765                if v == "$LATEST" {
766                    (0, 0)
767                } else {
768                    (1, v.parse::<u64>().unwrap_or(u64::MAX))
769                }
770            };
771            let start = match marker.as_deref() {
772                Some(m) => {
773                    let mr = version_rank(m);
774                    all.iter()
775                        .position(|v| version_rank(v["Version"].as_str().unwrap_or("")) > mr)
776                        .unwrap_or(all.len())
777                }
778                None => 0,
779            };
780            let end = (start + max_items).min(all.len());
781            let page: Vec<serde_json::Value> = all[start..end].to_vec();
782            let mut body = json!({ "Versions": page });
783            if end < all.len() {
784                if let Some(last) = all[end - 1]["Version"].as_str() {
785                    body["NextMarker"] = json!(last);
786                }
787            }
788            ok(body)
789        })
790    }
791
792    fn pc_key(function: &str, qualifier: &str) -> String {
793        format!("{function}:{qualifier}")
794    }
795
796    // ── Tags ──
797
798    fn tag_resource(
799        &self,
800        resource_arn: &str,
801        req: &AwsRequest,
802    ) -> Result<AwsResponse, AwsServiceError> {
803        let body = body(req);
804        let new_tags: Vec<(String, String)> = body
805            .get("Tags")
806            .and_then(|v| v.as_object())
807            .map(|m| {
808                m.iter()
809                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
810                    .collect()
811            })
812            .unwrap_or_default();
813        // SDKs URL-encode `:` in the path so the ARN arrives as
814        // `arn%3Aaws%3Alambda%3A...`; decode before parsing.
815        let resource_arn_decoded = decode_query_segment(resource_arn);
816        let name = function_name_from_arn(&resource_arn_decoded).ok_or_else(|| {
817            AwsServiceError::aws_error(
818                StatusCode::BAD_REQUEST,
819                "InvalidParameterValueException",
820                format!("Resource ARN is not a Lambda function: {resource_arn_decoded}"),
821            )
822        })?;
823        let mut accounts = self.state.write();
824        let state = accounts.get_or_create(&req.account_id);
825        let func = state.functions.get_mut(&name).ok_or_else(|| {
826            AwsServiceError::aws_error(
827                StatusCode::NOT_FOUND,
828                "ResourceNotFoundException",
829                format!("Function not found: {name}"),
830            )
831        })?;
832        // Single source of truth: per-function `tags`. `GetFunction`,
833        // `ListTagsForResource`, and `UntagResource` all read here.
834        for (k, v) in new_tags {
835            func.tags.insert(k, v);
836        }
837        empty()
838    }
839
840    fn untag_resource(
841        &self,
842        resource_arn: &str,
843        req: &AwsRequest,
844    ) -> Result<AwsResponse, AwsServiceError> {
845        // AWS sends keys as repeated `tagKeys=K1&tagKeys=K2` query
846        // params per the Smithy model (`httpQuery: "tagKeys"`). The
847        // dispatcher's deduplicated `query_params` HashMap collapses
848        // repeats, so parse the raw query string for every occurrence.
849        // Also accept `tagKeys.1=K1` / `tagKeys.member.1=K1` for SDKs
850        // that serialize list params indexed-style.
851        //
852        // As a defensive fallback we also accept a JSON body of the
853        // form `{"TagKeys": [...]}` / `{"tagKeys": [...]}` for clients
854        // that mistakenly send the tag keys in the body. Query
855        // parameters win when both are present, since query is the
856        // AWS-canonical wire format.
857        let mut keys: Vec<String> = Vec::new();
858        for (k, v) in parse_query_pairs(&req.raw_query) {
859            if k == "tagKeys" || k.starts_with("tagKeys.") {
860                keys.push(v);
861            }
862        }
863        if keys.is_empty() {
864            let parsed = body(req);
865            for field in ["TagKeys", "tagKeys"] {
866                if let Some(arr) = parsed.get(field).and_then(|v| v.as_array()) {
867                    for v in arr {
868                        if let Some(s) = v.as_str() {
869                            keys.push(s.to_string());
870                        }
871                    }
872                    if !keys.is_empty() {
873                        break;
874                    }
875                }
876            }
877        }
878        let resource_arn_decoded = decode_query_segment(resource_arn);
879        let name = function_name_from_arn(&resource_arn_decoded).ok_or_else(|| {
880            AwsServiceError::aws_error(
881                StatusCode::BAD_REQUEST,
882                "InvalidParameterValueException",
883                format!("Resource ARN is not a Lambda function: {resource_arn_decoded}"),
884            )
885        })?;
886        let mut accounts = self.state.write();
887        let state = accounts.get_or_create(&req.account_id);
888        let func = state.functions.get_mut(&name).ok_or_else(|| {
889            AwsServiceError::aws_error(
890                StatusCode::NOT_FOUND,
891                "ResourceNotFoundException",
892                format!("Function not found: {name}"),
893            )
894        })?;
895        for k in &keys {
896            func.tags.remove(k);
897        }
898        empty()
899    }
900
901    fn list_tags(
902        &self,
903        resource_arn: &str,
904        account_id: &str,
905    ) -> Result<AwsResponse, AwsServiceError> {
906        let resource_arn_decoded = decode_query_segment(resource_arn);
907        let name = function_name_from_arn(&resource_arn_decoded).ok_or_else(|| {
908            AwsServiceError::aws_error(
909                StatusCode::BAD_REQUEST,
910                "InvalidParameterValueException",
911                format!("Resource ARN is not a Lambda function: {resource_arn_decoded}"),
912            )
913        })?;
914        let region = self.region_for(account_id);
915        self.with_state_read(account_id, &region, |state| {
916            let func = state.functions.get(&name).ok_or_else(|| {
917                AwsServiceError::aws_error(
918                    StatusCode::NOT_FOUND,
919                    "ResourceNotFoundException",
920                    format!("Function not found: {name}"),
921                )
922            })?;
923            let tags: serde_json::Map<String, Value> = func
924                .tags
925                .iter()
926                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
927                .collect();
928            ok(json!({"Tags": tags}))
929        })
930    }
931
932    // ── Capacity providers ──
933
934    fn update_event_source_mapping_handler(
935        &self,
936        uuid: &str,
937        req: &AwsRequest,
938    ) -> Result<AwsResponse, AwsServiceError> {
939        let body = body(req);
940        let mut accounts = self.state.write();
941        let state = accounts.get_or_create(&req.account_id);
942        let esm = state
943            .event_source_mappings
944            .get_mut(uuid)
945            .ok_or_else(|| not_found("EventSourceMapping", uuid))?;
946        if let Some(b) = body["BatchSize"].as_i64() {
947            esm.batch_size = b;
948        }
949        if let Some(name) = body["FunctionName"].as_str() {
950            esm.function_arn = format!(
951                "arn:aws:lambda:{}:{}:function:{}",
952                req.region, state.account_id, name
953            );
954        }
955        if let Some(filters) = body
956            .get("FilterCriteria")
957            .and_then(|v| v.get("Filters"))
958            .and_then(|v| v.as_array())
959        {
960            esm.filter_patterns = filters
961                .iter()
962                .filter_map(|f| f.get("Pattern").and_then(|p| p.as_str()).map(String::from))
963                .collect();
964        }
965        if let Some(types) = body.get("FunctionResponseTypes").and_then(|v| v.as_array()) {
966            esm.function_response_types = types
967                .iter()
968                .filter_map(|v| v.as_str().map(String::from))
969                .collect();
970        }
971        if let Some(w) = body
972            .get("MaximumBatchingWindowInSeconds")
973            .and_then(|v| v.as_i64())
974        {
975            esm.maximum_batching_window_in_seconds = Some(w);
976        }
977        if let Some(p) = body.get("ParallelizationFactor").and_then(|v| v.as_i64()) {
978            esm.parallelization_factor = Some(p);
979        }
980        if let Some(s) = body.get("KMSKeyArn").and_then(|v| v.as_str()) {
981            esm.kms_key_arn = Some(s.to_string());
982        }
983        if let Some(mc) = body.get("MetricsConfig") {
984            esm.metrics_config = Some(mc.clone());
985        }
986        if let Some(dc) = body.get("DestinationConfig") {
987            esm.destination_config = Some(dc.clone());
988        }
989        if let Some(n) = body.get("MaximumRetryAttempts").and_then(|v| v.as_i64()) {
990            esm.maximum_retry_attempts = Some(n);
991        }
992        if let Some(n) = body
993            .get("MaximumRecordAgeInSeconds")
994            .and_then(|v| v.as_i64())
995        {
996            esm.maximum_record_age_in_seconds = Some(n);
997        }
998        if let Some(b) = body
999            .get("BisectBatchOnFunctionError")
1000            .and_then(|v| v.as_bool())
1001        {
1002            esm.bisect_batch_on_function_error = Some(b);
1003        }
1004        if let Some(n) = body.get("TumblingWindowInSeconds").and_then(|v| v.as_i64()) {
1005            esm.tumbling_window_in_seconds = Some(n);
1006        }
1007        // Enabled toggles the ESM State between Enabled/Disabled. AWS
1008        // momentarily returns Enabling/Disabling but settles to the terminal
1009        // state; we report the terminal state directly since there's no poller
1010        // lag to model.
1011        if let Some(enabled) = body.get("Enabled").and_then(|v| v.as_bool()) {
1012            esm.enabled = enabled;
1013            esm.state = if enabled { "Enabled" } else { "Disabled" }.to_string();
1014        }
1015        // SourceAccessConfigurations (Kafka/MQ/MSK VPC + auth config). Replace
1016        // wholesale when provided so credential/subnet updates actually persist
1017        // rather than vanishing silently (1.1).
1018        if let Some(sac) = body
1019            .get("SourceAccessConfigurations")
1020            .and_then(|v| v.as_array())
1021        {
1022            esm.source_access_configurations = sac.clone();
1023        }
1024        esm.last_modified = chrono::Utc::now();
1025        // Reuse the shared serializer so Update echoes the same full field set
1026        // (State, SourceAccessConfigurations, KMSKeyArn, DestinationConfig,
1027        // MetricsConfig, MaximumRetryAttempts, ...) that Create/Get/List emit.
1028        let response = self.event_source_mapping_json(esm);
1029        ok(response)
1030    }
1031
1032    fn region_for(&self, account_id: &str) -> String {
1033        let accounts = self.state.read();
1034        accounts
1035            .get(account_id)
1036            .map(|s| s.region.clone())
1037            .unwrap_or_else(|| "us-east-1".to_string())
1038    }
1039}
1040
1041fn extract_csc_id(input: &str) -> String {
1042    // Decode percent encoding then take the segment after the last colon
1043    // (csc id), or treat as id if no colon present.
1044    let decoded = percent_decode(input);
1045    decoded.rsplit(':').next().unwrap_or(&decoded).to_string()
1046}
1047
1048/// Re-export of `percent_decode` for the service-mod length check.
1049/// Wraps the private helper without changing its signature.
1050pub(crate) fn percent_decode_for_length(input: &str) -> String {
1051    percent_decode(input)
1052}
1053
1054fn percent_decode(input: &str) -> String {
1055    let mut out = String::with_capacity(input.len());
1056    let bytes = input.as_bytes();
1057    let mut i = 0;
1058    while i < bytes.len() {
1059        if bytes[i] == b'%' && i + 2 < bytes.len() {
1060            let hi = (bytes[i + 1] as char).to_digit(16);
1061            let lo = (bytes[i + 2] as char).to_digit(16);
1062            if let (Some(h), Some(l)) = (hi, lo) {
1063                out.push(((h * 16 + l) as u8) as char);
1064                i += 3;
1065                continue;
1066            }
1067        }
1068        out.push(bytes[i] as char);
1069        i += 1;
1070    }
1071    out
1072}
1073
1074fn code_signing_json(c: &CodeSigningConfig) -> Value {
1075    json!({
1076        "CodeSigningConfigId": c.csc_id,
1077        "CodeSigningConfigArn": c.csc_arn,
1078        "Description": c.description,
1079        "AllowedPublishers": {
1080            "SigningProfileVersionArns": c.allowed_publishers,
1081        },
1082        "CodeSigningPolicies": {
1083            "UntrustedArtifactOnDeployment": c.untrusted_artifact_action,
1084        },
1085        "LastModified": c.last_modified.format("%Y-%m-%dT%H:%M:%S.%3fZ").to_string(),
1086    })
1087}
1088
1089fn event_invoke_json(c: &EventInvokeConfig) -> Value {
1090    // `DestinationConfig` serialization mirrors the three AWS behaviours
1091    // documented in the Smithy `@examples` for Put/UpdateFunctionEventInvokeConfig
1092    // and asserted by the conformance round-trip strategy:
1093    //
1094    //   stored `None`          (input omitted entirely)
1095    //       -> emit `{OnSuccess:{}, OnFailure:{}}`  (Put example)
1096    //   stored `Some({})`      (caller sent `{}`)
1097    //       -> echo `{}` verbatim                    (round-trip strategy)
1098    //   stored `Some({half})`  (one side configured)
1099    //       -> backfill the other half as `{}`       (Update example)
1100    let destination = match &c.destination_config {
1101        None => json!({"OnSuccess": {}, "OnFailure": {}}),
1102        Some(v) if !v.is_object() => json!({}),
1103        Some(v) => {
1104            let mut map = v.as_object().cloned().unwrap_or_default();
1105            if !map.is_empty() {
1106                map.entry("OnSuccess".to_string()).or_insert(json!({}));
1107                map.entry("OnFailure".to_string()).or_insert(json!({}));
1108            }
1109            Value::Object(map)
1110        }
1111    };
1112    let mut out = json!({
1113        "FunctionArn": c.function_arn,
1114        "MaximumRetryAttempts": c.maximum_retry_attempts,
1115        "DestinationConfig": destination,
1116        // `LastModified` is bound to Smithy's `Date` shape
1117        // (`type: timestamp`). The default REST-JSON serialization
1118        // for `timestamp` is an epoch-seconds float, which is what
1119        // `aws-sdk-lambda` deserializes; emitting an ISO string here
1120        // makes the SDK panic on `f64::from_str("2026-...")`.
1121        "LastModified": c
1122            .last_modified
1123            .timestamp_millis() as f64
1124            / 1000.0,
1125    });
1126    // AWS only reports MaximumEventAgeInSeconds once the caller set it.
1127    if c.maximum_event_age != 0 {
1128        out["MaximumEventAgeInSeconds"] = json!(c.maximum_event_age);
1129    }
1130    out
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use crate::service::LambdaService;
1136    use crate::state::{LambdaState, SharedLambdaState};
1137    use fakecloud_core::multi_account::MultiAccountState;
1138    use fakecloud_core::service::AwsRequest;
1139    use http::Method;
1140    use parking_lot::RwLock;
1141    use std::collections::HashMap;
1142    use std::sync::Arc;
1143
1144    fn svc() -> LambdaService {
1145        let state: SharedLambdaState = Arc::new(RwLock::new(
1146            MultiAccountState::<LambdaState>::new("000000000000", "us-east-1", ""),
1147        ));
1148        LambdaService::new(state)
1149    }
1150
1151    fn req(action: &str, body: &str, segs: &[&str]) -> AwsRequest {
1152        AwsRequest {
1153            service: "lambda".to_string(),
1154            method: Method::POST,
1155            raw_path: format!("/{}", segs.join("/")),
1156            raw_query: String::new(),
1157            path_segments: segs.iter().map(|s| s.to_string()).collect(),
1158            query_params: HashMap::new(),
1159            headers: http::HeaderMap::new(),
1160            body: bytes::Bytes::from(body.to_string()),
1161            body_stream: parking_lot::Mutex::new(None),
1162            account_id: "000000000000".to_string(),
1163            region: "us-east-1".to_string(),
1164            request_id: "rid".to_string(),
1165            action: action.to_string(),
1166            is_query_protocol: false,
1167            access_key_id: None,
1168            principal: None,
1169        }
1170    }
1171
1172    async fn run(s: &LambdaService, action: &str, body: &str, res: Option<&str>, segs: &[&str]) {
1173        let r = s.handle_extra(action, res, &req(action, body, segs)).await;
1174        match r {
1175            Ok(resp) => assert!(resp.status.is_success(), "{action} status: {}", resp.status),
1176            Err(e) => panic!("{action} failed: {e:?}"),
1177        }
1178    }
1179
1180    #[tokio::test]
1181    async fn read_only_listings_succeed_without_state() {
1182        let s = svc();
1183        run(&s, "GetAccountSettings", "", None, &[]).await;
1184        run(&s, "InvokeAsync", r#"{}"#, Some("fn"), &[]).await;
1185        run(&s, "ListLayers", "", None, &[]).await;
1186        run(&s, "ListLayerVersions", "", Some("layer"), &[]).await;
1187    }
1188
1189    #[tokio::test]
1190    async fn layers_lifecycle() {
1191        let s = svc();
1192        run(
1193            &s,
1194            "PublishLayerVersion",
1195            r#"{"Content":{"ZipFile":""}}"#,
1196            Some("layer1"),
1197            &["2018-10-31", "layers", "layer1", "versions"],
1198        )
1199        .await;
1200        run(&s, "ListLayers", "", None, &[]).await;
1201        run(&s, "ListLayerVersions", "", Some("layer1"), &[]).await;
1202    }
1203
1204    #[tokio::test]
1205    async fn code_signing_lifecycle() {
1206        let s = svc();
1207        run(
1208            &s,
1209            "CreateCodeSigningConfig",
1210            r#"{"AllowedPublishers":{"SigningProfileVersionArns":[]}}"#,
1211            None,
1212            &[],
1213        )
1214        .await;
1215        run(&s, "ListCodeSigningConfigs", "", None, &[]).await;
1216    }
1217}