Skip to content

Commit 4aa1c04

Browse files
authored
fix(lambda): close the remaining contract validation gaps (#7241)
* fix(lambda): close the remaining contract validation gaps Follow-up to #7216, from review on the release PR. Every bound added here is one the AWS Lambda API reference documents; findings that asked for undocumented limits were left alone. Code source selection had two holes. `hasS3` required both S3 fields, so a partial pair alongside `imageUri` read as "image only" and the stray S3 field still went to AWS; and an `imageUri` with no `packageType` was accepted even though the package type then defaults to Zip. Both now fail at the boundary, naming the field to change, and the zip-only `s3ObjectVersion` and `sourceKmsKeyArn` count as S3 fields for the exclusivity check. The VPC guard only checked that both lists were supplied, so supplying both with one empty passed and produced a partial update. Both must now be empty (detach) or both populated (attach). Documented bounds added: - alias names: 1-128 and the documented pattern, which excludes all-digit names - descriptions: 256 characters - layer names: 140 characters and the name-or-ARN pattern - optional `functionName` on the event source mapping operations: 1-256 - `RemovePermission` statement IDs: 1-100 and its own pattern, which allows a dot where `AddPermission` does not Also rejects values that are structurally meaningless rather than merely out of range: empty tag keys, empty Kafka bootstrap servers, more than one weighted routing entry, and an event source mapping that supplies both an event source ARN and self-managed Kafka bootstrap servers. * fix(lambda): match twelve digits in the layer ARN pattern The account-ID segment was written as `\d{12}` inside a template literal, so the emitted regex carried a literal `d{12}` and rejected every real layer ARN. The existing test only covered an over-long name, which is why it passed. Escapes the backslash and adds the coverage that would have caught it: a real layer ARN and a bare layer name are both accepted, and an ARN whose account segment is not twelve digits is rejected. * fix(lambda): reject empty code-source fields instead of ignoring them The mutual-exclusivity check used truthiness, so `imageUri` alongside `s3Bucket: ''` read as image-only while the create operation still forwarded the defined empty field to AWS. An empty string is meaningless for every code-source field, so each is now `.min(1)` at the contract rather than special-cased in the refinement. * fix(lambda): reject empty optional strings across the Lambda contracts An empty `eventSourceArn` alongside bootstrap servers slipped past the mutual-exclusivity check for the same reason the code-source fields did: the guard tests truthiness, so a defined-but-empty value reads as absent while the operation still forwards it. Rather than patch each field as it surfaces, every optional string field now rejects an empty value. The tool layer already drops `''` before it reaches a contract, so an empty value can only arrive from a malformed direct call, and forwarding it to AWS is never right. `description` is exempt: AWS documents it as "Minimum length of 0", so an empty value legitimately clears it. Both behaviours are covered by tests. * fix(lambda): stop rejecting values AWS documents as valid A comprehensive validation pass against the API reference found the previous commit's blanket "no empty optional strings" rule was wrong. Several Lambda parameters document an empty string as meaningful, and their patterns say so: KMSKeyArn, SourceKMSKeyArn, and DeadLetterConfig.TargetArn all carry `(arn:...)|()`, whose trailing alternative matches the empty string, and the on-success/on-failure destinations document `Minimum length of 0` with a pattern beginning `$|`. For each, empty is how the setting is cleared. The rule is now opt-in rather than opt-out: only the five fields feeding a truthiness-based cross-field check reject an empty value. That removes 47 constraints and leaves the ones that were actually reported. Also from the same pass: - Supplying an image URI no longer demands an explicit `packageType`. That subBlock is advanced with no default, so requiring it produced a 400 naming a control the user cannot see; the operation derives Image from the code source instead, and only an explicit Zip alongside an image is rejected. - `fileSystemConfigs` was the one projection without a null guard, so an omitted field vanished from the block output rather than reading null. - An absent function URL now maps to null instead of an empty string a workflow could build a request against. - GetFunction reports `tagsError`, so a partial tag-read failure is distinguishable from a function with no tags, and marks `configuration` nullable to match what the operation returns. - Event source mappings report `selfManagedKafkaBootstrapServers`, which could be set but never read back. - TagResource rejects an empty tag map instead of reporting "0 tags applied". * test(lambda): prove every projection matches its contract schema Reading code confirmed the projections and schemas agree, but nothing ran them against each other. This runs all eight shared mappers against their schemas in both directions: every declared key is emitted and non-undefined, no undeclared key is emitted, and the result parses — for an empty AWS response, which is the common case, and for a fully-populated one. Verified the suite fails when either defect class is reintroduced: a mapper that stops emitting a declared key, and a projection that leaks `undefined` where the schema declares a value.
1 parent e4c0a9f commit 4aa1c04

27 files changed

Lines changed: 761 additions & 73 deletions

apps/docs/content/docs/en/integrations/lambda.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ Get a function's configuration, code location, tags, and reserved concurrency
115115
| Parameter | Type | Description |
116116
| --------- | ---- | ----------- |
117117
| `configuration` | json | The function's configuration \(ARN, runtime, handler, memory, state, layers, VPC, and logging settings\) |
118+
| `tagsError` | json | Why the tags could not be read, when a partial tag-read failure occurred |
118119
| `code` | json | Presigned download URL for the deployment package, or the container image URI |
119120
| `tags` | json | The function's tags |
120121
| `reservedConcurrentExecutions` | number | Concurrency reserved for this function, if any |

apps/sim/lib/api/contracts/tools/aws/lambda-create-alias.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@ const CreateAliasSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
aliasName: z.string().min(1, 'aliasName is required'),
19+
aliasName: z
20+
.string()
21+
.min(1, 'aliasName is required')
22+
.max(128, 'aliasName cannot exceed 128 characters')
23+
.regex(
24+
/^(?![0-9]+$)[a-zA-Z0-9-_]+$/,
25+
'aliasName may only contain letters, numbers, hyphens, and underscores, and cannot be all digits'
26+
),
2027
aliasFunctionVersion: z.string().min(1, 'aliasFunctionVersion is required'),
21-
description: z.string().optional(),
28+
description: z.string().max(256, 'description cannot exceed 256 characters').optional(),
2229
additionalVersionWeights: z
2330
.record(
2431
z.string().regex(/^[0-9]+$/, 'routing keys must be published version numbers'),
@@ -27,6 +34,10 @@ const CreateAliasSchema = z.object({
2734
.min(0, 'a routing weight cannot be negative')
2835
.max(1, 'a routing weight cannot exceed 1')
2936
)
37+
.refine(
38+
(weights) => Object.keys(weights).length <= 1,
39+
'additionalVersionWeights routes to a single second version, so it accepts at most one entry'
40+
)
3041
.optional(),
3142
})
3243

apps/sim/lib/api/contracts/tools/aws/lambda-create-event-source-mapping.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const CreateEventSourceMappingSchema = z
1818
.string()
1919
.min(1, 'functionName is required')
2020
.max(256, 'functionName cannot exceed 256 characters'),
21-
eventSourceArn: z.string().optional(),
21+
eventSourceArn: z.string().min(1, 'eventSourceArn cannot be empty').optional(),
2222
enabled: z.boolean().optional(),
2323
batchSize: z.number().int().min(1).max(10000).optional(),
2424
maximumBatchingWindowInSeconds: z.number().int().min(0).max(300).optional(),
@@ -54,16 +54,26 @@ const CreateEventSourceMappingSchema = z
5454
documentDbFullDocument: z.enum(['UpdateLookup', 'Default']).optional(),
5555
amazonManagedKafkaConsumerGroupId: z.string().optional(),
5656
selfManagedKafkaConsumerGroupId: z.string().optional(),
57-
selfManagedKafkaBootstrapServers: z.array(z.string()).optional(),
57+
selfManagedKafkaBootstrapServers: z
58+
.array(z.string().min(1, 'a bootstrap server cannot be empty'))
59+
.optional(),
5860
})
5961
.superRefine((value, ctx) => {
60-
if (!value.eventSourceArn && !value.selfManagedKafkaBootstrapServers?.length) {
62+
const hasBootstrapServers = Boolean(value.selfManagedKafkaBootstrapServers?.length)
63+
if (!value.eventSourceArn && !hasBootstrapServers) {
6164
ctx.addIssue({
6265
code: 'custom',
6366
path: ['eventSourceArn'],
6467
message:
6568
'An event source is required: provide eventSourceArn, or selfManagedKafkaBootstrapServers for a self-managed Kafka cluster',
6669
})
70+
} else if (value.eventSourceArn && hasBootstrapServers) {
71+
ctx.addIssue({
72+
code: 'custom',
73+
path: ['selfManagedKafkaBootstrapServers'],
74+
message:
75+
'A mapping has one event source: provide eventSourceArn, or selfManagedKafkaBootstrapServers, not both',
76+
})
6777
}
6878
if (value.startingPosition === 'AT_TIMESTAMP' && !value.startingPositionTimestamp) {
6979
ctx.addIssue({

apps/sim/lib/api/contracts/tools/aws/lambda-create-function.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ const CreateFunctionSchema = z
2121
runtime: z.string().optional(),
2222
handler: z.string().optional(),
2323
packageType: z.enum(['Zip', 'Image']).optional(),
24-
s3Bucket: z.string().optional(),
25-
s3Key: z.string().optional(),
26-
s3ObjectVersion: z.string().optional(),
27-
imageUri: z.string().optional(),
24+
s3Bucket: z.string().min(1, 's3Bucket cannot be empty').optional(),
25+
s3Key: z.string().min(1, 's3Key cannot be empty').optional(),
26+
s3ObjectVersion: z.string().min(1, 's3ObjectVersion cannot be empty').optional(),
27+
imageUri: z.string().min(1, 'imageUri cannot be empty').optional(),
2828
sourceKmsKeyArn: z.string().optional(),
29-
description: z.string().optional(),
29+
description: z.string().max(256, 'description cannot exceed 256 characters').optional(),
3030
functionTimeout: z.number().int().min(1).max(900).optional(),
3131
memorySize: z.number().int().min(128).max(32768).optional(),
3232
ephemeralStorageSize: z.number().int().min(512).max(10240).optional(),
@@ -48,36 +48,41 @@ const CreateFunctionSchema = z
4848
logGroup: z.string().optional(),
4949
})
5050
.superRefine((value, ctx) => {
51+
const hasAnyZipField = Boolean(
52+
value.s3Bucket || value.s3Key || value.s3ObjectVersion || value.sourceKmsKeyArn
53+
)
5154
const hasS3 = Boolean(value.s3Bucket && value.s3Key)
52-
if (!hasS3 && !value.imageUri) {
55+
if (value.imageUri && hasAnyZipField) {
5356
ctx.addIssue({
5457
code: 'custom',
55-
path: ['s3Bucket'],
58+
path: ['imageUri'],
5659
message:
57-
'A code source is required: provide s3Bucket and s3Key for a .zip package, or imageUri for a container image',
60+
'Provide either a .zip package (s3Bucket, s3Key, s3ObjectVersion, sourceKmsKeyArn) or imageUri, not both',
5861
})
5962
return
6063
}
61-
if (hasS3 && value.imageUri) {
64+
if (!value.imageUri && !hasS3) {
6265
ctx.addIssue({
6366
code: 'custom',
64-
path: ['imageUri'],
65-
message: 'Provide either an S3 package or imageUri, not both',
67+
path: hasAnyZipField ? ['s3Key'] : ['s3Bucket'],
68+
message: hasAnyZipField
69+
? 's3Bucket and s3Key must be provided together for a .zip package'
70+
: 'A code source is required: provide s3Bucket and s3Key for a .zip package, or imageUri for a container image',
6671
})
6772
return
6873
}
69-
if (value.packageType === 'Image' && hasS3) {
74+
if (value.imageUri && value.packageType === 'Zip') {
7075
ctx.addIssue({
7176
code: 'custom',
72-
path: ['imageUri'],
73-
message: 'packageType Image requires imageUri, not an S3 package',
77+
path: ['packageType'],
78+
message: 'packageType Zip requires an S3 package, not imageUri',
7479
})
7580
}
76-
if (value.packageType === 'Zip' && value.imageUri) {
81+
if (hasS3 && value.packageType === 'Image') {
7782
ctx.addIssue({
7883
code: 'custom',
79-
path: ['s3Bucket'],
80-
message: 'packageType Zip requires an S3 package, not imageUri',
84+
path: ['imageUri'],
85+
message: 'packageType Image requires imageUri, not an S3 package',
8186
})
8287
}
8388
if (hasS3) {
@@ -96,15 +101,26 @@ const CreateFunctionSchema = z
96101
})
97102
}
98103
}
99-
const hasSubnets = value.vpcSubnetIds !== undefined
100-
const hasSecurityGroups = value.vpcSecurityGroupIds !== undefined
101-
if (hasSubnets !== hasSecurityGroups) {
104+
const subnetIds = value.vpcSubnetIds
105+
const securityGroupIds = value.vpcSecurityGroupIds
106+
if ((subnetIds === undefined) !== (securityGroupIds === undefined)) {
102107
ctx.addIssue({
103108
code: 'custom',
104-
path: [hasSubnets ? 'vpcSecurityGroupIds' : 'vpcSubnetIds'],
109+
path: [subnetIds === undefined ? 'vpcSubnetIds' : 'vpcSecurityGroupIds'],
105110
message:
106111
'vpcSubnetIds and vpcSecurityGroupIds must be supplied together: send both lists to attach a VPC, or both empty to detach',
107112
})
113+
} else if (
114+
subnetIds !== undefined &&
115+
securityGroupIds !== undefined &&
116+
(subnetIds.length === 0) !== (securityGroupIds.length === 0)
117+
) {
118+
ctx.addIssue({
119+
code: 'custom',
120+
path: [subnetIds.length === 0 ? 'vpcSubnetIds' : 'vpcSecurityGroupIds'],
121+
message:
122+
'vpcSubnetIds and vpcSecurityGroupIds must both be empty to detach, or both be populated to attach',
123+
})
108124
}
109125
})
110126

apps/sim/lib/api/contracts/tools/aws/lambda-delete-alias.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ const DeleteAliasSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
aliasName: z.string().min(1, 'aliasName is required'),
19+
aliasName: z
20+
.string()
21+
.min(1, 'aliasName is required')
22+
.max(128, 'aliasName cannot exceed 128 characters')
23+
.regex(
24+
/^(?![0-9]+$)[a-zA-Z0-9-_]+$/,
25+
'aliasName may only contain letters, numbers, hyphens, and underscores, and cannot be all digits'
26+
),
2027
})
2128

2229
const DeleteAliasResponseSchema = lambdaMessageResponseSchema

apps/sim/lib/api/contracts/tools/aws/lambda-get-alias.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ const GetAliasSchema = z.object({
1616
.string()
1717
.min(1, 'functionName is required')
1818
.max(256, 'functionName cannot exceed 256 characters'),
19-
aliasName: z.string().min(1, 'aliasName is required'),
19+
aliasName: z
20+
.string()
21+
.min(1, 'aliasName is required')
22+
.max(128, 'aliasName cannot exceed 128 characters')
23+
.regex(
24+
/^(?![0-9]+$)[a-zA-Z0-9-_]+$/,
25+
'aliasName may only contain letters, numbers, hyphens, and underscores, and cannot be all digits'
26+
),
2027
})
2128

2229
const GetAliasResponseSchema = z.object({

apps/sim/lib/api/contracts/tools/aws/lambda-get-function.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ const GetFunctionResponseSchema = z.object({
2727
success: z.literal(true),
2828
output: z.object({
2929
configuration: lambdaFunctionConfigurationSchema.nullable(),
30+
tagsError: z
31+
.object({ errorCode: z.string().nullable(), message: z.string().nullable() })
32+
.nullable(),
3033
code: z
3134
.object({
3235
repositoryType: z.string().nullable(),

apps/sim/lib/api/contracts/tools/aws/lambda-get-layer-version.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1212

1313
const GetLayerVersionSchema = z.object({
1414
...lambdaConnectionFields,
15-
layerName: z.string().min(1, 'layerName is required'),
15+
layerName: z
16+
.string()
17+
.min(1, 'layerName is required')
18+
.max(140, 'layerName cannot exceed 140 characters')
19+
.regex(
20+
/^(arn:[a-zA-Z0-9-]+:lambda:[a-zA-Z0-9-]+:\d{12}:layer:[a-zA-Z0-9-_]+)$|^[a-zA-Z0-9-_]+$/,
21+
'layerName must be a layer name or a layer ARN'
22+
),
1623
versionNumber: z.number().int().min(1, 'versionNumber must be at least 1'),
1724
})
1825

apps/sim/lib/api/contracts/tools/aws/lambda-list-event-source-mappings.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1414
const ListEventSourceMappingsSchema = z.object({
1515
...lambdaConnectionFields,
1616
...lambdaPaginationFields,
17-
functionName: z.string().optional(),
18-
eventSourceArn: z.string().optional(),
17+
functionName: z
18+
.string()
19+
.min(1, 'functionName cannot be empty')
20+
.max(256, 'functionName cannot exceed 256 characters')
21+
.optional(),
22+
eventSourceArn: z.string().min(1, 'eventSourceArn cannot be empty').optional(),
1923
})
2024

2125
const ListEventSourceMappingsResponseSchema = z.object({

apps/sim/lib/api/contracts/tools/aws/lambda-list-layer-versions.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,14 @@ import { defineRouteContract } from '@/lib/api/contracts/types'
1414
const ListLayerVersionsSchema = z.object({
1515
...lambdaConnectionFields,
1616
...lambdaSmallPaginationFields,
17-
layerName: z.string().min(1, 'layerName is required'),
17+
layerName: z
18+
.string()
19+
.min(1, 'layerName is required')
20+
.max(140, 'layerName cannot exceed 140 characters')
21+
.regex(
22+
/^(arn:[a-zA-Z0-9-]+:lambda:[a-zA-Z0-9-]+:\d{12}:layer:[a-zA-Z0-9-_]+)$|^[a-zA-Z0-9-_]+$/,
23+
'layerName must be a layer name or a layer ARN'
24+
),
1825
compatibleRuntime: z.string().optional(),
1926
compatibleArchitecture: z.enum(['x86_64', 'arm64']).optional(),
2027
})

0 commit comments

Comments
 (0)