diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index b8436aa6cd..8caa4ae44d 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -190,7 +190,18 @@ func (c *Collection) Build(ctx context.Context, input *BuildInput) (*BuildOutput return nil, err } ciphertext := gcm.Seal(nil, make([]byte, 12), json, nil) - err = os.WriteFile(filepath.Join(result.Out, "resource.enc"), ciphertext, 0644) + // When a shared bundle is used, multiple functions share the same output + // directory (result.Out == input.Bundle). Writing "resource.enc" from + // concurrent Runtime.Build calls races: partial writes can leave a + // truncated or mixed ciphertext that fails AES-GCM authentication on + // the Lambda's first cold start (observed as `Decipheriv` errors at + // runtime initialization). Namespace the file by FunctionID so each + // function writes to its own path and reads it back via SST_KEY_FILE. + filename := "resource.enc" + if input.Bundle != "" { + filename = fmt.Sprintf("resource-%s.enc", input.FunctionID) + } + err = os.WriteFile(filepath.Join(result.Out, filename), ciphertext, 0644) if err != nil { return nil, err } diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index 69dc5f169d..a1cda9ae54 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -2,6 +2,9 @@ package runtime_test import ( "context" + "encoding/base64" + "encoding/json" + "os" "path/filepath" "testing" @@ -77,3 +80,65 @@ func TestCollectionRuntime(t *testing.T) { assert.False(t, ok) }) } + +func TestCollectionBuildEncryptedResourceFileWithBundle(t *testing.T) { + // A 32-byte AES-256 key (all zeroes is fine for testing purposes). + encryptionKey := base64.StdEncoding.EncodeToString(make([]byte, 32)) + + t.Run("uses per-function filename when bundle is set", func(t *testing.T) { + bundleDir := t.TempDir() + + mr := &mockRuntime{matchFn: func(r string) bool { return r == "nodejs" }} + c := runtime.NewCollection("cfg", mr) + + input := &runtime.BuildInput{ + FunctionID: "my-function", + Handler: "index.handler", + Bundle: bundleDir, + Runtime: "nodejs", + EncryptionKey: encryptionKey, + Links: map[string]json.RawMessage{}, + } + + _, err := c.Build(context.Background(), input) + require.NoError(t, err) + + // Per-function file must exist so concurrent Build calls sharing the + // same bundle directory don't race on "resource.enc". + perFunctionPath := filepath.Join(bundleDir, "resource-my-function.enc") + _, err = os.Stat(perFunctionPath) + assert.NoError(t, err, "expected %s to exist", perFunctionPath) + + // Default filename is reserved for the non-bundle (per-function + // artifact directory) path. + defaultPath := filepath.Join(bundleDir, "resource.enc") + _, err = os.Stat(defaultPath) + assert.True(t, os.IsNotExist(err), "default resource.enc should not exist when bundle is set") + }) + + t.Run("distinct functions sharing a bundle write distinct files", func(t *testing.T) { + bundleDir := t.TempDir() + + mr := &mockRuntime{matchFn: func(r string) bool { return r == "nodejs" }} + c := runtime.NewCollection("cfg", mr) + + for _, id := range []string{"fn-a", "fn-b"} { + input := &runtime.BuildInput{ + FunctionID: id, + Handler: "index.handler", + Bundle: bundleDir, + Runtime: "nodejs", + EncryptionKey: encryptionKey, + Links: map[string]json.RawMessage{}, + } + _, err := c.Build(context.Background(), input) + require.NoError(t, err) + } + + for _, id := range []string{"fn-a", "fn-b"} { + p := filepath.Join(bundleDir, "resource-"+id+".enc") + _, err := os.Stat(p) + assert.NoError(t, err, "expected %s to exist", p) + } + }) +} diff --git a/platform/src/components/aws/function.ts b/platform/src/components/aws/function.ts index 1d9d8a39c6..edbe3e2de2 100644 --- a/platform/src/components/aws/function.ts +++ b/platform/src/components/aws/function.ts @@ -1885,8 +1885,9 @@ export class Function extends Component implements Link.Linkable { Function.encryptionKey().base64, args.link, args.streaming, + args.bundle, dev.apply((dev) => dev ? Function.appsync() : undefined), - ]).apply(([environment, dev, bootstrap, key, link, streaming, appsync]) => { + ]).apply(([environment, dev, bootstrap, key, link, streaming, bundle, appsync]) => { const result = environment ?? {}; result.SST_RESOURCE_App = JSON.stringify({ name: $app.name, @@ -1900,7 +1901,11 @@ export class Function extends Component implements Link.Linkable { } } result.SST_KEY = key; - result.SST_KEY_FILE = "resource.enc"; + // When a shared `bundle` is used, multiple functions write into the + // same output directory — the Go runtime namespaces the encrypted + // resource file by FunctionID to avoid a concurrent-write race that + // corrupts the ciphertext. Point each Lambda at its own file to match. + result.SST_KEY_FILE = bundle ? `resource-${name}.enc` : "resource.enc"; if (dev) { result.SST_REGION = process.env.SST_AWS_REGION!; result.SST_APPSYNC_HTTP = appsync.http;