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..b124f69a 100644 --- a/crates/fakecloud-e2e/tests/lambda.rs +++ b/crates/fakecloud-e2e/tests/lambda.rs @@ -67,6 +67,98 @@ async fn lambda_create_get_delete_function() { assert!(result.is_err()); } +/// Issue #817: AWS Lambda accepts `FunctionName` in four forms (plain +/// name, qualified name, full ARN, partial ARN). The AWS Toolkit for +/// VS Code calls `GetFunction` with the full ARN, which previously +/// returned 404. Cover every shape end-to-end. +#[tokio::test] +async fn lambda_function_name_accepts_all_arn_forms() { + let server = TestServer::start().await; + let client = server.lambda_client().await; + + client + .create_function() + .function_name("toolkit-fn") + .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 — the form the AWS Toolkit for VS Code sends. + let resp = client + .get_function() + .function_name("arn:aws:lambda:us-east-1:123456789012:function:toolkit-fn") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "toolkit-fn" + ); + + // Partial ARN. + let resp = client + .get_function() + .function_name("123456789012:function:toolkit-fn") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "toolkit-fn" + ); + + // Qualified full ARN. + let resp = client + .get_function() + .function_name("arn:aws:lambda:us-east-1:123456789012:function:toolkit-fn:$LATEST") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "toolkit-fn" + ); + + // Plain name with qualifier. + let resp = client + .get_function() + .function_name("toolkit-fn:$LATEST") + .send() + .await + .unwrap(); + assert_eq!( + resp.configuration().unwrap().function_name().unwrap(), + "toolkit-fn" + ); + + // DeleteFunction must accept the same forms — using the full ARN + // here proves the resolver applies to mutating ops too. + client + .delete_function() + .function_name("arn:aws:lambda:us-east-1:123456789012:function:toolkit-fn") + .send() + .await + .unwrap(); + + let err = client + .get_function() + .function_name("toolkit-fn") + .send() + .await; + assert!( + err.is_err(), + "function should be gone after ARN-form delete" + ); +} + #[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..460af475 100644 --- a/crates/fakecloud-lambda/src/service.rs +++ b/crates/fakecloud-lambda/src/service.rs @@ -39,16 +39,14 @@ struct CreateFunctionInput { impl CreateFunctionInput { fn from_body(body: &Value) -> Result { - let function_name = body["FunctionName"] - .as_str() - .ok_or_else(|| { - AwsServiceError::aws_error( - StatusCode::BAD_REQUEST, - "InvalidParameterValueException", - "FunctionName is required", - ) - })? - .to_string(); + let raw_function_name = body["FunctionName"].as_str().ok_or_else(|| { + AwsServiceError::aws_error( + StatusCode::BAD_REQUEST, + "InvalidParameterValueException", + "FunctionName is required", + ) + })?; + let function_name = resolve_function_name(raw_function_name); let tags: HashMap = body["Tags"] .as_object() @@ -138,6 +136,136 @@ impl CreateFunctionInput { } } +/// Resolve a Lambda `FunctionName` parameter to the bare function name +/// used as the `state.functions` map key. +/// +/// AWS Lambda accepts four equivalent forms wherever `FunctionName` is +/// taken: +/// - plain name: `my-fn` +/// - plain name with qualifier: `my-fn:PROD` or `my-fn:7` +/// - full ARN: `arn:aws:lambda:REGION:ACCOUNT:function:my-fn[:QUALIFIER]` +/// - partial ARN: `ACCOUNT:function:my-fn[:QUALIFIER]` +/// +/// AWS SDKs URL-encode the colons in ARN-form path components +/// (`arn%3Aaws%3Alambda%3A...`), so the input is percent-decoded first. +/// +/// Internally fakecloud only ever stores the unqualified name, so each +/// caller must collapse these forms before doing a map lookup. Inputs +/// that don't match any known shape pass through unchanged so +/// downstream `not found` errors keep their original identifier. +pub(crate) fn resolve_function_name(input: &str) -> String { + let decoded: std::borrow::Cow<'_, str> = if input.contains('%') { + percent_encoding::percent_decode_str(input) + .decode_utf8() + .unwrap_or(std::borrow::Cow::Borrowed(input)) + } else { + std::borrow::Cow::Borrowed(input) + }; + let s: &str = decoded.as_ref(); + + // Full ARN: arn:aws:lambda:REGION:ACCOUNT:function:NAME[:QUALIFIER] + if let Some(rest) = s.strip_prefix("arn:aws:lambda:") { + let parts: Vec<&str> = rest.splitn(5, ':').collect(); + if parts.len() >= 4 + && !parts[0].is_empty() + && !parts[1].is_empty() + && parts[2] == "function" + && !parts[3].is_empty() + { + return parts[3].to_string(); + } + return s.to_string(); + } + // Partial ARN: ACCOUNT:function:NAME[:QUALIFIER] (account is 12 digits). + let head: Vec<&str> = s.splitn(4, ':').collect(); + if head.len() >= 3 + && head[0].len() == 12 + && head[0].bytes().all(|b| b.is_ascii_digit()) + && head[1] == "function" + && !head[2].is_empty() + { + return head[2].to_string(); + } + // Plain name with optional qualifier (`my-fn` or `my-fn:PROD`). + // Lambda qualifiers can't contain `:` (`$LATEST | [a-zA-Z0-9-_]+ | + // [0-9]+`), so a well-formed qualified name has at most one colon. + // Multi-colon inputs (foreign ARNs, garbage) pass through so the + // caller surfaces a 404 with the original identifier instead of a + // silently truncated prefix. + if s.matches(':').count() == 1 { + if let Some((name, _qualifier)) = s.split_once(':') { + if is_valid_function_name(name) { + return name.to_string(); + } + } + } + s.to_string() +} + +/// Lambda's `FunctionName` parameter regex: `[a-zA-Z0-9-_]+`. Used to +/// gate the qualifier-stripping fallback in [`resolve_function_name`] +/// so unrelated identifiers (other-service ARNs, garbage) pass through +/// unchanged instead of being silently truncated. +fn is_valid_function_name(s: &str) -> bool { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} + +/// True when the action's path-derived resource is a Lambda +/// `FunctionName` (versus a layer name, code-signing-config id, event- +/// source-mapping uuid, capacity-provider name, durable-execution id, +/// or a tag-resource ARN). Drives whether `handle()` collapses ARN / +/// partial-ARN / qualified forms to the bare function name. +fn is_function_name_action(action: &str) -> bool { + matches!( + action, + "GetFunction" + | "DeleteFunction" + | "Invoke" + | "InvokeAsync" + | "InvokeWithResponseStream" + | "PublishVersion" + | "AddPermission" + | "GetPolicy" + | "RemovePermission" + | "GetFunctionConfiguration" + | "UpdateFunctionConfiguration" + | "UpdateFunctionCode" + | "ListVersionsByFunction" + | "CreateAlias" + | "GetAlias" + | "ListAliases" + | "UpdateAlias" + | "DeleteAlias" + | "CreateFunctionUrlConfig" + | "GetFunctionUrlConfig" + | "UpdateFunctionUrlConfig" + | "DeleteFunctionUrlConfig" + | "PutFunctionConcurrency" + | "GetFunctionConcurrency" + | "DeleteFunctionConcurrency" + | "PutProvisionedConcurrencyConfig" + | "GetProvisionedConcurrencyConfig" + | "DeleteProvisionedConcurrencyConfig" + | "ListProvisionedConcurrencyConfigs" + | "PutFunctionEventInvokeConfig" + | "UpdateFunctionEventInvokeConfig" + | "GetFunctionEventInvokeConfig" + | "DeleteFunctionEventInvokeConfig" + | "ListFunctionEventInvokeConfigs" + | "PutRuntimeManagementConfig" + | "GetRuntimeManagementConfig" + | "PutFunctionScalingConfig" + | "GetFunctionScalingConfig" + | "PutFunctionRecursionConfig" + | "GetFunctionRecursionConfig" + | "PutFunctionCodeSigningConfig" + | "GetFunctionCodeSigningConfig" + | "DeleteFunctionCodeSigningConfig" + ) +} + /// AWS Lambda's InvocationType: synchronous, async (event), or dry-run. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InvocationType { @@ -1104,17 +1232,18 @@ impl LambdaService { let mut accounts = self.state.write(); let state = accounts.get_or_create(&req.account_id); - // Resolve function name to ARN - let function_arn = if function_name.starts_with("arn:") { - function_name.clone() - } else { - let func = state.functions.get(&function_name).ok_or_else(|| { + // Resolve to a canonical function ARN. Accept name, qualified + // name, partial ARN, or full ARN — collapse to the bare name, + // then look up the stored ARN. Mirrors AWS's flexibility. + let bare_name = resolve_function_name(&function_name); + let function_arn = { + let func = state.functions.get(&bare_name).ok_or_else(|| { AwsServiceError::aws_error( StatusCode::NOT_FOUND, "ResourceNotFoundException", format!( "Function not found: arn:aws:lambda:{}:{}:function:{}", - state.region, state.account_id, function_name + state.region, state.account_id, bare_name ), ) })?; @@ -1617,6 +1746,19 @@ impl AwsService for LambdaService { ) })?; + // For actions where path[2] is a FunctionName, accept all four + // AWS-supported forms (plain name, qualified name, full ARN, + // partial ARN) by collapsing to the bare function name before + // hitting any state.functions lookup. Tag/EventSourceMapping/ + // Layer/CodeSigningConfig/CapacityProvider/DurableExecution + // resources keep their original identifier — they're not + // FunctionName-keyed. + let resource_name = if is_function_name_action(action) { + resource_name.map(|s| resolve_function_name(&s)) + } else { + resource_name + }; + let mutates = matches!( action, "CreateFunction" @@ -1853,7 +1995,8 @@ impl AwsService for LambdaService { let resource = match action { "GetFunction" | "DeleteFunction" | "InvokeFunction" | "PublishVersion" | "AddPermission" | "RemovePermission" | "GetPolicy" => { - let name = resource_name.unwrap_or_default(); + let raw = resource_name.unwrap_or_default(); + let name = resolve_function_name(&raw); if name.is_empty() { "*".to_string() } else { @@ -1874,7 +2017,9 @@ impl AwsService for LambdaService { v.get("FunctionName").and_then(|f| f.as_str()).map(|n| { format!( "arn:aws:lambda:{}:{}:function:{}", - state.region, state.account_id, n + state.region, + state.account_id, + resolve_function_name(n) ) }) }) @@ -2615,4 +2760,196 @@ mod tests { let resp = svc.list_event_source_mappings("123456789012").unwrap(); assert_eq!(resp.status, http::StatusCode::OK); } + + #[test] + fn resolve_function_name_plain() { + assert_eq!(resolve_function_name("my-fn"), "my-fn"); + } + + #[test] + fn resolve_function_name_qualified_plain() { + assert_eq!(resolve_function_name("my-fn:PROD"), "my-fn"); + assert_eq!(resolve_function_name("my-fn:7"), "my-fn"); + assert_eq!(resolve_function_name("my-fn:$LATEST"), "my-fn"); + } + + #[test] + fn resolve_function_name_full_arn() { + assert_eq!( + resolve_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn"), + "my-fn" + ); + } + + #[test] + fn resolve_function_name_qualified_full_arn() { + assert_eq!( + resolve_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn:PROD"), + "my-fn" + ); + assert_eq!( + resolve_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn:7"), + "my-fn" + ); + assert_eq!( + resolve_function_name("arn:aws:lambda:us-east-1:123456789012:function:my-fn:$LATEST"), + "my-fn" + ); + } + + #[test] + fn resolve_function_name_partial_arn() { + assert_eq!( + resolve_function_name("123456789012:function:my-fn"), + "my-fn" + ); + } + + #[test] + fn resolve_function_name_qualified_partial_arn() { + assert_eq!( + resolve_function_name("123456789012:function:my-fn:7"), + "my-fn" + ); + assert_eq!( + resolve_function_name("123456789012:function:my-fn:PROD"), + "my-fn" + ); + } + + #[test] + fn resolve_function_name_empty() { + assert_eq!(resolve_function_name(""), ""); + } + + #[test] + fn resolve_function_name_partial_arn_requires_12_digit_account() { + // First segment isn't 12 digits so it doesn't match the partial + // ARN shape. With multiple colons the qualifier-stripping + // fallback declines (a qualified name has exactly one colon), + // so the input passes through and the caller 404s with the + // original identifier — not a silently truncated `acct`. + assert_eq!( + resolve_function_name("acct:function:my-fn"), + "acct:function:my-fn" + ); + } + + #[test] + fn resolve_function_name_foreign_arn_passthrough() { + // ARNs from other services don't match Lambda's ARN shape and + // contain colons that must NOT be treated as qualifier + // separators (`arn` isn't a valid function name, so the + // fallback declines to truncate). + assert_eq!( + resolve_function_name("arn:aws:s3:::my-bucket"), + "arn:aws:s3:::my-bucket" + ); + assert_eq!( + resolve_function_name("arn:aws:sns:us-east-1:123456789012:my-topic"), + "arn:aws:sns:us-east-1:123456789012:my-topic" + ); + assert_eq!( + resolve_function_name("arn:aws:iam::123456789012:role/my-role"), + "arn:aws:iam::123456789012:role/my-role" + ); + } + + #[test] + fn resolve_function_name_percent_encoded_arn() { + // AWS SDKs URL-encode the colons in ARN-form path segments; + // `arn%3Aaws%3Alambda%3A...` must collapse to the bare name. + assert_eq!( + resolve_function_name( + "arn%3Aaws%3Alambda%3Aus-east-1%3A123456789012%3Afunction%3Atoolkit-fn" + ), + "toolkit-fn" + ); + assert_eq!( + resolve_function_name( + "arn%3aaws%3alambda%3aus-east-1%3a123456789012%3afunction%3atoolkit-fn%3a%24LATEST" + ), + "toolkit-fn" + ); + } + + #[test] + fn resolve_function_name_unknown_arn_passthrough() { + // An ARN we don't recognise (wrong service / wrong shape) is + // returned unchanged so the caller surfaces a 404 with the + // original identifier instead of silently truncating it. + assert_eq!( + resolve_function_name("arn:aws:lambda:us-east-1:123456789012:layer:my-layer"), + "arn:aws:lambda:us-east-1:123456789012:layer:my-layer" + ); + assert_eq!( + resolve_function_name("arn:aws:lambda:::function:my-fn"), + "arn:aws:lambda:::function:my-fn" + ); + } + + #[tokio::test] + async fn get_function_accepts_full_arn_in_path() { + let svc = LambdaService::new(make_state()); + seed_function(&svc, "toolkit-fn").await; + + let req = make_request( + Method::GET, + "/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:toolkit-fn", + "", + ); + let resp = svc.handle(req).await.unwrap(); + assert_eq!(resp.status, StatusCode::OK); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!(body["Configuration"]["FunctionName"], "toolkit-fn"); + } + + #[tokio::test] + async fn get_function_accepts_partial_arn_in_path() { + let svc = LambdaService::new(make_state()); + seed_function(&svc, "toolkit-fn").await; + + let req = make_request( + Method::GET, + "/2015-03-31/functions/123456789012:function:toolkit-fn", + "", + ); + let resp = svc.handle(req).await.unwrap(); + assert_eq!(resp.status, StatusCode::OK); + let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap(); + assert_eq!(body["Configuration"]["FunctionName"], "toolkit-fn"); + } + + #[tokio::test] + async fn get_function_accepts_qualified_arn_in_path() { + let svc = LambdaService::new(make_state()); + seed_function(&svc, "toolkit-fn").await; + + let req = make_request( + Method::GET, + "/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:toolkit-fn:$LATEST", + "", + ); + let resp = svc.handle(req).await.unwrap(); + assert_eq!(resp.status, StatusCode::OK); + } + + #[tokio::test] + async fn delete_function_accepts_full_arn_in_path() { + let svc = LambdaService::new(make_state()); + seed_function(&svc, "toolkit-fn").await; + + let req = make_request( + Method::DELETE, + "/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:toolkit-fn", + "", + ); + let resp = svc.handle(req).await.unwrap(); + assert_eq!(resp.status, StatusCode::NO_CONTENT); + + // Confirm the function is actually gone. + let req = make_request(Method::GET, "/2015-03-31/functions/toolkit-fn", ""); + let resp = svc.handle(req).await; + assert!(resp.is_err()); + } }