From c5e8bcb8336ee305a5b9e2a583840c0016256cdd Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 28 Apr 2026 09:30:15 -0300 Subject: [PATCH 1/2] fix(lambda): accept ARN, partial ARN, and qualified names in URL paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #817. The VS Code AWS Toolkit calls GetFunction with a fully-qualified function ARN (`arn:aws:lambda:REGION:ACCOUNT:function:NAME`), which real AWS Lambda accepts in any URL slot that names a function. fakecloud was looking up the raw URL segment in the in-memory map keyed by short name, so every toolkit request returned 404 and the Lambda explorer in the IDE showed nothing. The fix: - New `normalize_function_name` helper strips full ARN, partial ARN (`ACCOUNT:function:NAME`), and trailing `:qualifier` (version or alias) down to the bare name. Inputs that don't match any of those shapes pass through unchanged. - Helper percent-decodes its input first, since SDKs URL-encode `:` in path segments (`arn%3Aaws%3Alambda%3A...`). - Applied at dispatch: `handle()` normalizes `resource_name` for every action that takes a `FunctionName` (Get/Delete/Invoke/etc., aliases, concurrency, URL config, event invoke config, recursion config, versions, policy, scaling). Layer / event-source-mapping routes are untouched — those carry different identifiers. Coverage: - 8 unit tests for the normalizer (bare name, qualifier strip, full ARN, qualified full ARN, partial ARN, malformed ARN passthrough, empty, percent-encoded ARN) - 3 unit tests exercising `LambdaService::handle` end-to-end with full ARN, partial ARN, and `name:qualifier` - 1 e2e test via aws-sdk-lambda calling `GetFunction` with all three forms — same code path the VS Code toolkit hits --- Cargo.lock | 1 + crates/fakecloud-e2e/tests/lambda.rs | 57 ++++++ crates/fakecloud-lambda/Cargo.toml | 1 + crates/fakecloud-lambda/src/service.rs | 262 +++++++++++++++++++++++++ 4 files changed, 321 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 25369e51..e478667b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2944,6 +2944,7 @@ dependencies = [ "fakecloud-persistence", "http 1.4.0", "parking_lot", + "percent-encoding", "reqwest", "serde", "serde_json", diff --git a/crates/fakecloud-e2e/tests/lambda.rs b/crates/fakecloud-e2e/tests/lambda.rs index 05933ac8..2df3cc14 100644 --- a/crates/fakecloud-e2e/tests/lambda.rs +++ b/crates/fakecloud-e2e/tests/lambda.rs @@ -67,6 +67,63 @@ async fn lambda_create_get_delete_function() { assert!(result.is_err()); } +#[tokio::test] +async fn lambda_get_function_accepts_arn_partial_arn_and_qualifier() { + let server = TestServer::start().await; + let client = server.lambda_client().await; + + client + .create_function() + .function_name("arn-target") + .runtime(aws_sdk_lambda::types::Runtime::Python312) + .role("arn:aws:iam::123456789012:role/test-role") + .handler("index.handler") + .code( + aws_sdk_lambda::types::FunctionCode::builder() + .zip_file(Blob::new(make_python_zip())) + .build(), + ) + .send() + .await + .unwrap(); + + // Full ARN — what the VS Code AWS Toolkit sends. + let resp = client + .get_function() + .function_name("arn:aws:lambda:us-east-1:123456789012:function:arn-target") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "arn-target" + ); + + // Partial ARN. + let resp = client + .get_function() + .function_name("123456789012:function:arn-target") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "arn-target" + ); + + // Bare name with version qualifier. + let resp = client + .get_function() + .function_name("arn-target:1") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "arn-target" + ); +} + #[tokio::test] async fn lambda_list_functions() { let server = TestServer::start().await; diff --git a/crates/fakecloud-lambda/Cargo.toml b/crates/fakecloud-lambda/Cargo.toml index df9cf8b9..0db287a6 100644 --- a/crates/fakecloud-lambda/Cargo.toml +++ b/crates/fakecloud-lambda/Cargo.toml @@ -26,6 +26,7 @@ reqwest = { workspace = true } tracing = { workspace = true } zip = { workspace = true } tempfile = { workspace = true } +percent-encoding = { workspace = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/fakecloud-lambda/src/service.rs b/crates/fakecloud-lambda/src/service.rs index 00768a0e..6395e578 100644 --- a/crates/fakecloud-lambda/src/service.rs +++ b/crates/fakecloud-lambda/src/service.rs @@ -17,6 +17,124 @@ use crate::state::{ LAMBDA_SNAPSHOT_SCHEMA_VERSION, }; +/// Lambda actions whose URL `resource_name` slot is a `FunctionName` +/// (and therefore accepts ARN / partial ARN / `name:qualifier` forms). +/// Layer / event-source-mapping / code-signing-config actions key off +/// other resource identifiers and are excluded. +pub(crate) fn action_takes_function_name(action: &str) -> bool { + matches!( + action, + "GetFunction" + | "DeleteFunction" + | "Invoke" + | "InvokeAsync" + | "InvokeWithResponseStream" + | "PublishVersion" + | "ListVersionsByFunction" + | "AddPermission" + | "RemovePermission" + | "GetPolicy" + | "GetFunctionConfiguration" + | "UpdateFunctionConfiguration" + | "UpdateFunctionCode" + | "GetFunctionConcurrency" + | "PutFunctionConcurrency" + | "DeleteFunctionConcurrency" + | "PutProvisionedConcurrencyConfig" + | "GetProvisionedConcurrencyConfig" + | "DeleteProvisionedConcurrencyConfig" + | "ListProvisionedConcurrencyConfigs" + | "PutFunctionEventInvokeConfig" + | "UpdateFunctionEventInvokeConfig" + | "GetFunctionEventInvokeConfig" + | "DeleteFunctionEventInvokeConfig" + | "ListFunctionEventInvokeConfigs" + | "CreateFunctionUrlConfig" + | "UpdateFunctionUrlConfig" + | "GetFunctionUrlConfig" + | "DeleteFunctionUrlConfig" + | "ListFunctionUrlConfigs" + | "PutFunctionCodeSigningConfig" + | "GetFunctionCodeSigningConfig" + | "DeleteFunctionCodeSigningConfig" + | "GetFunctionScalingConfig" + | "PutFunctionRecursionConfig" + | "GetFunctionRecursionConfig" + | "CreateAlias" + | "GetAlias" + | "ListAliases" + | "UpdateAlias" + | "DeleteAlias" + ) +} + +/// Strip an ARN, partial ARN, or trailing `:qualifier` from a Lambda +/// `FunctionName` input down to the bare function name used as the +/// state map key. AWS Lambda accepts four forms in URL path slots and +/// API params: +/// +/// - `MyFunction` +/// - `MyFunction:Qualifier` +/// - `123456789012:function:MyFunction[:Qualifier]` (partial ARN) +/// - `arn:aws:lambda:REGION:ACCOUNT:function:MyFunction[:Qualifier]` +/// +/// Inputs that don't match any of those structures are returned +/// unchanged. The qualifier (version or alias) is dropped because most +/// callers look up the function by name and resolve qualifier +/// separately. +pub(crate) fn normalize_function_name(input: &str) -> String { + if input.is_empty() { + return String::new(); + } + + // SDKs URL-encode `:` in path segments, so `arn:aws:lambda:...` + // arrives as `arn%3Aaws%3Alambda%3A...`. Decode first; legitimate + // function names contain no percent-encoded characters, so this is + // safe for the bare-name path too. + let decoded = percent_encoding::percent_decode_str(input) + .decode_utf8_lossy() + .into_owned(); + let input = decoded.as_str(); + + // Full ARN: arn:aws:lambda:REGION:ACCOUNT:function:NAME[:QUALIFIER] + if let Some(rest) = input.strip_prefix("arn:aws:lambda:") { + let parts: Vec<&str> = rest.splitn(5, ':').collect(); + // parts: [region, account, "function", name, qualifier?] + if parts.len() >= 4 && parts[2] == "function" && !parts[3].is_empty() { + return parts[3].to_string(); + } + return input.to_string(); + } + + // Partial ARN: ACCOUNT:function:NAME[:QUALIFIER] + let parts: Vec<&str> = input.splitn(4, ':').collect(); + if parts.len() >= 3 && parts[1] == "function" && parts[0].chars().all(|c| c.is_ascii_digit()) { + if !parts[2].is_empty() { + return parts[2].to_string(); + } + return input.to_string(); + } + + // Bare name with qualifier: NAME:QUALIFIER. Only apply when the + // input contains exactly one colon and the name part is a valid + // Lambda function-name token, so malformed ARNs (e.g. wrong service + // or wrong format) fall through unchanged rather than getting their + // first colon-segment returned. + if input.matches(':').count() == 1 { + if let Some((name, _qualifier)) = input.split_once(':') { + if !name.is_empty() && name.chars().all(is_function_name_char) { + return name.to_string(); + } + } + } + + input.to_string() +} + +fn is_function_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' +} + /// All fields of a `CreateFunction` request, already parsed and /// defaulted. The code zip (if any) is eagerly base64-decoded so the /// caller can hash it without doing the decode again. @@ -1617,6 +1735,16 @@ impl AwsService for LambdaService { ) })?; + // Normalize FunctionName-bearing resource slots: AWS Lambda accepts + // bare name, name:qualifier, partial ARN, and full ARN in any URL + // slot that names a function. Layer / event-source-mapping resource + // names go through different routes and are left as-is. + let resource_name = if action_takes_function_name(action) { + resource_name.map(|s| normalize_function_name(&s)) + } else { + resource_name + }; + let mutates = matches!( action, "CreateFunction" @@ -1953,6 +2081,140 @@ mod tests { } } + #[test] + fn normalize_function_name_bare_name_passes_through() { + assert_eq!(normalize_function_name("MyFunction"), "MyFunction"); + } + + #[test] + fn normalize_function_name_strips_qualifier_from_bare_name() { + assert_eq!(normalize_function_name("MyFunction:PROD"), "MyFunction"); + assert_eq!(normalize_function_name("MyFunction:1"), "MyFunction"); + } + + #[test] + fn normalize_function_name_strips_full_arn() { + assert_eq!( + normalize_function_name("arn:aws:lambda:us-east-1:123456789012:function:MyFunction"), + "MyFunction" + ); + } + + #[test] + fn normalize_function_name_strips_qualified_full_arn() { + assert_eq!( + normalize_function_name( + "arn:aws:lambda:us-east-1:123456789012:function:MyFunction:PROD" + ), + "MyFunction" + ); + } + + #[test] + fn normalize_function_name_strips_partial_arn() { + assert_eq!( + normalize_function_name("123456789012:function:MyFunction"), + "MyFunction" + ); + assert_eq!( + normalize_function_name("123456789012:function:MyFunction:1"), + "MyFunction" + ); + } + + #[test] + fn normalize_function_name_leaves_malformed_arn_alone() { + // wrong service in ARN — multiple colons, no lambda prefix → unchanged + let s = "arn:aws:s3:us-east-1:123456789012:function:Foo"; + assert_eq!(normalize_function_name(s), s); + // partial ARN with non-numeric account-shaped prefix → unchanged + let s2 = "abc:function:Foo"; + assert_eq!(normalize_function_name(s2), s2); + } + + #[test] + fn normalize_function_name_empty() { + assert_eq!(normalize_function_name(""), ""); + } + + #[test] + fn normalize_function_name_decodes_percent_encoded_arn() { + // SDKs URL-encode `:` in path segments. The toolkit / aws-sdk-lambda + // wire form for `arn:aws:lambda:...` is `arn%3Aaws%3Alambda%3A...`. + let encoded = "arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Afunction%3AMyFunc"; + assert_eq!(normalize_function_name(encoded), "MyFunc"); + } + + #[tokio::test] + async fn get_function_accepts_full_arn() { + let svc = LambdaService::new(make_state()); + // Seed a function via CreateFunction + let create_body = json!({ + "FunctionName": "MyFunc", + "Runtime": "nodejs20.x", + "Role": "arn:aws:iam::123456789012:role/lambda-role", + "Handler": "index.handler", + "Code": {"ZipFile": ""}, + }) + .to_string(); + let req = make_request(Method::POST, "/2015-03-31/functions", &create_body); + svc.handle(req).await.expect("create function"); + + // GetFunction by full ARN + let req = make_request( + Method::GET, + "/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:MyFunc", + "", + ); + let resp = svc.handle(req).await.expect("get function by ARN"); + assert_eq!(resp.status, StatusCode::OK); + } + + #[tokio::test] + async fn get_function_accepts_partial_arn() { + let svc = LambdaService::new(make_state()); + let create_body = json!({ + "FunctionName": "MyFunc", + "Runtime": "nodejs20.x", + "Role": "arn:aws:iam::123456789012:role/lambda-role", + "Handler": "index.handler", + "Code": {"ZipFile": ""}, + }) + .to_string(); + let req = make_request(Method::POST, "/2015-03-31/functions", &create_body); + svc.handle(req).await.expect("create function"); + + let req = make_request( + Method::GET, + "/2015-03-31/functions/123456789012:function:MyFunc", + "", + ); + let resp = svc.handle(req).await.expect("get function by partial ARN"); + assert_eq!(resp.status, StatusCode::OK); + } + + #[tokio::test] + async fn get_function_accepts_name_with_qualifier() { + let svc = LambdaService::new(make_state()); + let create_body = json!({ + "FunctionName": "MyFunc", + "Runtime": "nodejs20.x", + "Role": "arn:aws:iam::123456789012:role/lambda-role", + "Handler": "index.handler", + "Code": {"ZipFile": ""}, + }) + .to_string(); + let req = make_request(Method::POST, "/2015-03-31/functions", &create_body); + svc.handle(req).await.expect("create function"); + + let req = make_request(Method::GET, "/2015-03-31/functions/MyFunc:1", ""); + let resp = svc + .handle(req) + .await + .expect("get function by name:qualifier"); + assert_eq!(resp.status, StatusCode::OK); + } + #[test] fn iam_condition_keys_for_add_permission_populates_arn_and_principal() { let svc = LambdaService::new(make_state()); From 55daeeb9c53b94c117c7171546c126ccef42eec1 Mon Sep 17 00:00:00 2001 From: Lucas Vieira Date: Tue, 28 Apr 2026 09:40:03 -0300 Subject: [PATCH 2/2] fix(lambda): include runtime-management + durable-executions routes in ARN normalization Cubic catch on #822: PutRuntimeManagementConfig, GetRuntimeManagementConfig, and ListDurableExecutionsByFunction also live under /functions/{name}/... and were missing from the action_takes_function_name allowlist, so they would still 404 on ARN / qualified-name input. --- crates/fakecloud-lambda/src/service.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/fakecloud-lambda/src/service.rs b/crates/fakecloud-lambda/src/service.rs index 6395e578..1f8396ae 100644 --- a/crates/fakecloud-lambda/src/service.rs +++ b/crates/fakecloud-lambda/src/service.rs @@ -65,6 +65,9 @@ pub(crate) fn action_takes_function_name(action: &str) -> bool { | "ListAliases" | "UpdateAlias" | "DeleteAlias" + | "PutRuntimeManagementConfig" + | "GetRuntimeManagementConfig" + | "ListDurableExecutionsByFunction" ) }