Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion pkg/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
65 changes: 65 additions & 0 deletions pkg/runtime/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package runtime_test

import (
"context"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"testing"

Expand Down Expand Up @@ -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)
}
})
}
9 changes: 7 additions & 2 deletions platform/src/components/aws/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down