diff --git a/internal/cmd/serverless/pack.go b/internal/cmd/serverless/pack.go index 35b2656..b36c67d 100644 --- a/internal/cmd/serverless/pack.go +++ b/internal/cmd/serverless/pack.go @@ -20,10 +20,15 @@ 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. +// 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. // // .gitignore is deliberately NOT consulted. What a project keeps out of version @@ -490,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