From 62bbc458baeb16a810a5c9f7cc9740a524fd994d Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 16:12:46 +0100 Subject: [PATCH 1/2] fix(serverless): cap a source archive at the API's 10 MiB limit The packer allowed 25 MiB, then the upload failed once the archive was past declaredByteLength's maximum of 10485760 bytes. --- internal/cmd/serverless/pack.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index 35b2656..de1b1c8 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -22,7 +22,8 @@ const maxPackEntryBytes int64 = 10 << 20 // 10 MiB // maxPackTotalBytes bounds the archive as a whole. The per-file cap alone does // not: a virtualenv is thousands of small files and would sail past it. -const maxPackTotalBytes int64 = 25 << 20 // 25 MiB +// 10 MiB matches SourceUploadCreate.declaredByteLength (maximum 10485760). +const maxPackTotalBytes int64 = 10 << 20 // 10 MiB // runwareIgnoreFile is the project's own exclude list, and the only one read. // From 04826977b626cbf1971cbf53cf525544834ecc40 Mon Sep 17 00:00:00 2001 From: ryank90 Date: Tue, 22 Sep 2026 19:27:44 +0100 Subject: [PATCH 2/2] fix(serverless): reject a zip that exceeds the upload size declaredByteLength is the compressed archive, not the uncompressed tree. The walk still bounds memory at 25 MiB; the zip is checked after it is closed. --- internal/cmd/serverless/pack.go | 18 ++++++++++++++---- internal/cmd/serverless/pack_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index de1b1c8..b36c67d 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -20,10 +20,14 @@ import ( // memory and base64 expands it by ~4/3. const maxPackEntryBytes int64 = 10 << 20 // 10 MiB -// maxPackTotalBytes bounds the archive as a whole. The per-file cap alone does -// not: a virtualenv is thousands of small files and would sail past it. -// 10 MiB matches SourceUploadCreate.declaredByteLength (maximum 10485760). -const maxPackTotalBytes int64 = 10 << 20 // 10 MiB +// maxPackTotalBytes bounds uncompressed file bytes held in memory while packing. +// The per-file cap alone does not: a virtualenv is thousands of small files +// and would sail past it. +const maxPackTotalBytes int64 = 25 << 20 // 25 MiB + +// maxArchiveBytes is SourceUploadCreate.declaredByteLength (maximum 10485760), +// which is the zip after compression, not the uncompressed tree. +const maxArchiveBytes int64 = 10 << 20 // 10 MiB // runwareIgnoreFile is the project's own exclude list, and the only one read. // @@ -491,6 +495,12 @@ func writeArchive(root string, files []packedFile) ([]byte, error) { if err := zw.Close(); err != nil { return nil, fmt.Errorf("close zip: %w", err) } + if int64(buf.Len()) > maxArchiveBytes { + return nil, fmt.Errorf( + "the packed archive is %s; the maximum is %s.\n%s\nExclude what the app does not need with a %s file", + humanBytes(int64(buf.Len())), humanBytes(maxArchiveBytes), largestFilesSummary(files), runwareIgnoreFile, + ) + } return buf.Bytes(), nil } diff --git a/internal/cmd/serverless/pack_test.go b/internal/cmd/serverless/pack_test.go index 834c0a2..92c3a12 100644 --- a/internal/cmd/serverless/pack_test.go +++ b/internal/cmd/serverless/pack_test.go @@ -3,6 +3,7 @@ package serverless import ( "archive/zip" "bytes" + "crypto/rand" "io" "os" "path/filepath" @@ -531,6 +532,33 @@ func TestPackDirectory_TotalTooLarge(t *testing.T) { } } +func TestWriteArchive_RejectsZipOverAPILimit(t *testing.T) { + dir := t.TempDir() + // Uncompressed total stays under maxPackTotalBytes; Deflate cannot shrink + // random bytes enough to fit declaredByteLength. + const chunk = 6 << 20 + for _, name := range []string{"a.bin", "b.bin"} { + buf := make([]byte, chunk) + if _, err := rand.Read(buf); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), buf, 0o600); err != nil { + t.Fatal(err) + } + } + + _, err := writeArchive(dir, []packedFile{ + {rel: "a.bin", size: chunk}, + {rel: "b.bin", size: chunk}, + }) + if err == nil { + t.Fatal("expected an error for a zip over declaredByteLength") + } + if !strings.Contains(err.Error(), "packed archive") || !strings.Contains(err.Error(), humanBytes(maxArchiveBytes)) { + t.Errorf("error %q does not name the zip cap", err) + } +} + // TestPackDirectory_ModelFileAlwaysPackedFromExcludedDirectory is the reviewer's // case: the exemption for the model file has to survive an ignore rule that // matches one of its ANCESTORS. The walk meets the directory first, and the