From 60294548e05945975abe35aaa382a99674690ff2 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Wed, 21 Jan 2026 09:47:28 +0100 Subject: [PATCH 1/8] refactor: remove per-file decompression size limit Remove MaxDecompressedFileSize as it's no longer necessary after migrating to a maintained JSON schema library (santhosh-tekuri/jsonschema/v6). The original limit was added to protect against vulnerabilities in an unmaintained library. The total decompressed chart size limit (MaxDecompressedChartSize) remains to protect against other attack vectors. Partially resolves #30738 Related: - https://github.com/helm/helm/pull/30743 Signed-off-by: Benoit Tigeot --- internal/chart/v3/loader/directory.go | 4 ---- pkg/chart/loader/archive/archive.go | 8 -------- pkg/chart/v2/loader/directory.go | 4 ---- 3 files changed, 16 deletions(-) diff --git a/internal/chart/v3/loader/directory.go b/internal/chart/v3/loader/directory.go index dfe3af3b2..5937efda9 100644 --- a/internal/chart/v3/loader/directory.go +++ b/internal/chart/v3/loader/directory.go @@ -100,10 +100,6 @@ func LoadDir(dir string) (*chart.Chart, error) { return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) } - if fi.Size() > archive.MaxDecompressedFileSize { - return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize) - } - data, err := os.ReadFile(name) if err != nil { return fmt.Errorf("error reading %s: %w", n, err) diff --git a/pkg/chart/loader/archive/archive.go b/pkg/chart/loader/archive/archive.go index a35c0152d..20a862759 100644 --- a/pkg/chart/loader/archive/archive.go +++ b/pkg/chart/loader/archive/archive.go @@ -37,10 +37,6 @@ import ( // The default value is 100 MiB. var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB -// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load. -// The size of the file is the decompressed version of it when it is stored in an archive. -var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB - var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) var utf8bom = []byte{0xEF, 0xBB, 0xBF} @@ -128,10 +124,6 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize) } - if hd.Size > MaxDecompressedFileSize { - return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize) - } - limitedReader := io.LimitReader(tr, remainingSize) bytesWritten, err := io.Copy(b, limitedReader) diff --git a/pkg/chart/v2/loader/directory.go b/pkg/chart/v2/loader/directory.go index 82578d924..e213a0da8 100644 --- a/pkg/chart/v2/loader/directory.go +++ b/pkg/chart/v2/loader/directory.go @@ -100,10 +100,6 @@ func LoadDir(dir string) (*chart.Chart, error) { return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) } - if fi.Size() > archive.MaxDecompressedFileSize { - return fmt.Errorf("chart file %q is larger than the maximum file size %d", fi.Name(), archive.MaxDecompressedFileSize) - } - data, err := os.ReadFile(name) if err != nil { return fmt.Errorf("error reading %s: %w", n, err) From 0ee6c7bc9d6bf5c96089d8bfb7a66472067934ac Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Wed, 28 Jan 2026 22:18:08 +0100 Subject: [PATCH 2/8] fix: deprecate MaxDecompressedFileSize instead of removing As Matt suggested we should keep the variable until v5 as it can be used because it is public. Related: - https://github.com/helm/helm/pull/31748#discussion_r2738518696 Signed-off-by: Benoit Tigeot --- pkg/chart/loader/archive/archive.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/chart/loader/archive/archive.go b/pkg/chart/loader/archive/archive.go index 20a862759..0abc0e272 100644 --- a/pkg/chart/loader/archive/archive.go +++ b/pkg/chart/loader/archive/archive.go @@ -37,6 +37,12 @@ import ( // The default value is 100 MiB. var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB +// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load. +// The size of the file is the decompressed version of it when it is stored in an archive. +// +// Deprecated: This variable is no longer used internally and will be removed in Helm v5. +var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB + var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) var utf8bom = []byte{0xEF, 0xBB, 0xBF} From bb213c06afcae8492cb2eb3ae59badc8eb12ec9b Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Fri, 27 Mar 2026 08:29:17 +0100 Subject: [PATCH 3/8] fix: enforce aggregate size budget on directory loading Directory-based chart loading (`LoadDir`) used unbounded `os.ReadFile` calls with no total size check. Archive loading already enforces `MaxDecompressedChartSize` via a remaining-byte budget but directory loading did not, leaving local charts and `file://` dependencies as an unbounded memory path. Add `ReadFileWithBudget` in the archive package and use it in both v2 and v3 directory loaders so they track the same aggregate budget. Ref: https://github.com/helm/helm/pull/31748#issuecomment-4138927643 Signed-off-by: Benoit Tigeot --- internal/chart/v3/loader/directory.go | 3 +- pkg/chart/loader/archive/budget.go | 43 +++++++++ pkg/chart/loader/archive/budget_test.go | 114 ++++++++++++++++++++++++ pkg/chart/v2/loader/directory.go | 3 +- pkg/chart/v2/loader/load_test.go | 14 +++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 pkg/chart/loader/archive/budget.go create mode 100644 pkg/chart/loader/archive/budget_test.go diff --git a/internal/chart/v3/loader/directory.go b/internal/chart/v3/loader/directory.go index 5937efda9..bc9a23ce1 100644 --- a/internal/chart/v3/loader/directory.go +++ b/internal/chart/v3/loader/directory.go @@ -64,6 +64,7 @@ func LoadDir(dir string) (*chart.Chart, error) { files := []*archive.BufferedFile{} topdir += string(filepath.Separator) + remaining := archive.MaxDecompressedChartSize walk := func(name string, fi os.FileInfo, err error) error { n := strings.TrimPrefix(name, topdir) @@ -100,7 +101,7 @@ func LoadDir(dir string) (*chart.Chart, error) { return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) } - data, err := os.ReadFile(name) + data, err := archive.ReadFileWithBudget(name, fi.Size(), &remaining) if err != nil { return fmt.Errorf("error reading %s: %w", n, err) } diff --git a/pkg/chart/loader/archive/budget.go b/pkg/chart/loader/archive/budget.go new file mode 100644 index 000000000..93cfbfd8a --- /dev/null +++ b/pkg/chart/loader/archive/budget.go @@ -0,0 +1,43 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archive + +import ( + "fmt" + "os" +) + +// ReadFileWithBudget reads a file and decrements remaining by the bytes read. +// It returns an error if the total would exceed MaxDecompressedChartSize. +func ReadFileWithBudget(path string, size int64, remaining *int64) ([]byte, error) { + if size > *remaining { + return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + // Re-check with actual length: the file may have grown between stat and read. + if int64(len(data)) > *remaining { + return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) + } + + *remaining -= int64(len(data)) + return data, nil +} diff --git a/pkg/chart/loader/archive/budget_test.go b/pkg/chart/loader/archive/budget_test.go new file mode 100644 index 000000000..72889638a --- /dev/null +++ b/pkg/chart/loader/archive/budget_test.go @@ -0,0 +1,114 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package archive + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestReadFileWithBudget(t *testing.T) { + dir := t.TempDir() + + writeFile := func(t *testing.T, name string, size int) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, make([]byte, size), 0644); err != nil { + t.Fatal(err) + } + return p + } + + expectedErr := fmt.Sprintf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) + + tcs := []struct { + name string + check func(t *testing.T) + }{ + { + name: "reads file and decrements budget", + check: func(t *testing.T) { + t.Helper() + p := writeFile(t, "small.txt", 100) + fi, _ := os.Stat(p) + remaining := int64(1000) + + data, err := ReadFileWithBudget(p, fi.Size(), &remaining) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(data) != 100 { + t.Fatalf("expected 100 bytes, got %d", len(data)) + } + if remaining != 900 { + t.Fatalf("expected remaining=900, got %d", remaining) + } + }, + }, + { + name: "rejects file exceeding budget", + check: func(t *testing.T) { + t.Helper() + p := writeFile(t, "big.txt", 500) + fi, _ := os.Stat(p) + remaining := int64(100) + + _, err := ReadFileWithBudget(p, fi.Size(), &remaining) + if err == nil { + t.Fatal("expected error for file exceeding budget") + } + if err.Error() != expectedErr { + t.Fatalf("expected %q, got %q", expectedErr, err.Error()) + } + if remaining != 100 { + t.Fatalf("budget should not change on rejection, got %d", remaining) + } + }, + }, + { + name: "tracks budget across multiple reads", + check: func(t *testing.T) { + t.Helper() + remaining := int64(250) + + for i := range 3 { + p := writeFile(t, fmt.Sprintf("f%d.txt", i), 80) + fi, _ := os.Stat(p) + if _, err := ReadFileWithBudget(p, fi.Size(), &remaining); err != nil { + t.Fatalf("read %d: unexpected error: %v", i, err) + } + } + if remaining != 10 { + t.Fatalf("expected remaining=10, got %d", remaining) + } + + p := writeFile(t, "over.txt", 20) + fi, _ := os.Stat(p) + _, err := ReadFileWithBudget(p, fi.Size(), &remaining) + if err == nil { + t.Fatal("expected error when cumulative reads exceed budget") + } + }, + }, + } + + for _, tc := range tcs { + t.Run(tc.name, tc.check) + } +} diff --git a/pkg/chart/v2/loader/directory.go b/pkg/chart/v2/loader/directory.go index e213a0da8..da6b275d5 100644 --- a/pkg/chart/v2/loader/directory.go +++ b/pkg/chart/v2/loader/directory.go @@ -64,6 +64,7 @@ func LoadDir(dir string) (*chart.Chart, error) { files := []*archive.BufferedFile{} topdir += string(filepath.Separator) + remaining := archive.MaxDecompressedChartSize walk := func(name string, fi os.FileInfo, err error) error { n := strings.TrimPrefix(name, topdir) @@ -100,7 +101,7 @@ func LoadDir(dir string) (*chart.Chart, error) { return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) } - data, err := os.ReadFile(name) + data, err := archive.ReadFileWithBudget(name, fi.Size(), &remaining) if err != nil { return fmt.Errorf("error reading %s: %w", n, err) } diff --git a/pkg/chart/v2/loader/load_test.go b/pkg/chart/v2/loader/load_test.go index aed071b2f..5fd844f38 100644 --- a/pkg/chart/v2/loader/load_test.go +++ b/pkg/chart/v2/loader/load_test.go @@ -51,6 +51,20 @@ func TestLoadDir(t *testing.T) { verifyDependenciesLock(t, c) } +func TestLoadDirExceedsBudget(t *testing.T) { + orig := archive.MaxDecompressedChartSize + archive.MaxDecompressedChartSize = 1 // 1 byte budget + defer func() { archive.MaxDecompressedChartSize = orig }() + + _, err := LoadDir("testdata/frobnitz") + if err == nil { + t.Fatal("expected error when chart directory exceeds budget") + } + if !strings.Contains(err.Error(), "chart exceeds maximum decompressed size") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestLoadDirWithDevNull(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test only works on unix systems with /dev/null present") From 0a59858527aa8e132814be828b1e525fe49f1ad5 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Fri, 27 Mar 2026 09:04:23 +0100 Subject: [PATCH 4/8] fix: cap directory budget reads with LimitReader Use `os.Open` + `io.LimitReader` instead of `os.ReadFile` in `ReadFileWithBudget` so a file that grows between stat and read cannot allocate unbounded memory. Also fix `MaxDecompressedFileSize` doc comment to reflect it is unused/deprecated, add nil guard on remaining, and check `os.Stat` errors in tests. Signed-off-by: Benoit Tigeot --- pkg/chart/loader/archive/archive.go | 6 +++--- pkg/chart/loader/archive/budget.go | 18 ++++++++++++++++-- pkg/chart/loader/archive/budget_test.go | 24 ++++++++++++++++++------ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/pkg/chart/loader/archive/archive.go b/pkg/chart/loader/archive/archive.go index 0abc0e272..633871bcc 100644 --- a/pkg/chart/loader/archive/archive.go +++ b/pkg/chart/loader/archive/archive.go @@ -37,10 +37,10 @@ import ( // The default value is 100 MiB. var MaxDecompressedChartSize int64 = 100 * 1024 * 1024 // Default 100 MiB -// MaxDecompressedFileSize is the size of the largest file that Helm will attempt to load. -// The size of the file is the decompressed version of it when it is stored in an archive. +// MaxDecompressedFileSize was the per-file size limit enforced during chart loading. +// It is no longer used internally; aggregate chart size is enforced via MaxDecompressedChartSize. // -// Deprecated: This variable is no longer used internally and will be removed in Helm v5. +// Deprecated: Retained for backward compatibility with external callers. Will be removed in Helm v5. var MaxDecompressedFileSize int64 = 5 * 1024 * 1024 // Default 5 MiB var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) diff --git a/pkg/chart/loader/archive/budget.go b/pkg/chart/loader/archive/budget.go index 93cfbfd8a..a820dec47 100644 --- a/pkg/chart/loader/archive/budget.go +++ b/pkg/chart/loader/archive/budget.go @@ -17,23 +17,37 @@ limitations under the License. package archive import ( + "errors" "fmt" + "io" "os" ) // ReadFileWithBudget reads a file and decrements remaining by the bytes read. // It returns an error if the total would exceed MaxDecompressedChartSize. +// The read is capped via io.LimitReader so a file that grows between stat +// and read cannot cause unbounded memory allocation. func ReadFileWithBudget(path string, size int64, remaining *int64) ([]byte, error) { + if remaining == nil { + return nil, errors.New("remaining budget must not be nil") + } if size > *remaining { return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) } - data, err := os.ReadFile(path) + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + // Read at most *remaining+1 bytes so we can detect over-budget without + // allocating unbounded memory if the file grew since stat. + data, err := io.ReadAll(io.LimitReader(f, *remaining+1)) if err != nil { return nil, err } - // Re-check with actual length: the file may have grown between stat and read. if int64(len(data)) > *remaining { return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) } diff --git a/pkg/chart/loader/archive/budget_test.go b/pkg/chart/loader/archive/budget_test.go index 72889638a..d8b01da97 100644 --- a/pkg/chart/loader/archive/budget_test.go +++ b/pkg/chart/loader/archive/budget_test.go @@ -46,7 +46,10 @@ func TestReadFileWithBudget(t *testing.T) { check: func(t *testing.T) { t.Helper() p := writeFile(t, "small.txt", 100) - fi, _ := os.Stat(p) + fi, err := os.Stat(p) + if err != nil { + t.Fatalf("failed to stat %s: %v", p, err) + } remaining := int64(1000) data, err := ReadFileWithBudget(p, fi.Size(), &remaining) @@ -66,10 +69,13 @@ func TestReadFileWithBudget(t *testing.T) { check: func(t *testing.T) { t.Helper() p := writeFile(t, "big.txt", 500) - fi, _ := os.Stat(p) + fi, err := os.Stat(p) + if err != nil { + t.Fatalf("failed to stat %s: %v", p, err) + } remaining := int64(100) - _, err := ReadFileWithBudget(p, fi.Size(), &remaining) + _, err = ReadFileWithBudget(p, fi.Size(), &remaining) if err == nil { t.Fatal("expected error for file exceeding budget") } @@ -89,7 +95,10 @@ func TestReadFileWithBudget(t *testing.T) { for i := range 3 { p := writeFile(t, fmt.Sprintf("f%d.txt", i), 80) - fi, _ := os.Stat(p) + fi, err := os.Stat(p) + if err != nil { + t.Fatalf("failed to stat %s: %v", p, err) + } if _, err := ReadFileWithBudget(p, fi.Size(), &remaining); err != nil { t.Fatalf("read %d: unexpected error: %v", i, err) } @@ -99,8 +108,11 @@ func TestReadFileWithBudget(t *testing.T) { } p := writeFile(t, "over.txt", 20) - fi, _ := os.Stat(p) - _, err := ReadFileWithBudget(p, fi.Size(), &remaining) + fi, err := os.Stat(p) + if err != nil { + t.Fatalf("failed to stat %s: %v", p, err) + } + _, err = ReadFileWithBudget(p, fi.Size(), &remaining) if err == nil { t.Fatal("expected error when cumulative reads exceed budget") } From cb07797566adc56ff8953805b598fed45ba9ec12 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Fri, 27 Mar 2026 15:27:26 +0100 Subject: [PATCH 5/8] test: add v3 directory loader budget test Mirror the v2 `TestLoadDirExceedsBudget` test for the v3 loader to prevent budget enforcement regressions in either path. Signed-off-by: Benoit Tigeot --- internal/chart/v3/loader/load_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go index c4c252407..7d5e237ff 100644 --- a/internal/chart/v3/loader/load_test.go +++ b/internal/chart/v3/loader/load_test.go @@ -51,6 +51,20 @@ func TestLoadDir(t *testing.T) { verifyDependenciesLock(t, c) } +func TestLoadDirExceedsBudget(t *testing.T) { + orig := archive.MaxDecompressedChartSize + archive.MaxDecompressedChartSize = 1 // 1 byte budget + defer func() { archive.MaxDecompressedChartSize = orig }() + + _, err := LoadDir("testdata/frobnitz") + if err == nil { + t.Fatal("expected error when chart directory exceeds budget") + } + if !strings.Contains(err.Error(), "chart exceeds maximum decompressed size") { + t.Fatalf("unexpected error: %v", err) + } +} + func TestLoadDirWithDevNull(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("test only works on unix systems with /dev/null present") From 868e210145deccafb4600baee91daed9eb1212b3 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Thu, 9 Apr 2026 21:45:31 +0200 Subject: [PATCH 6/8] refactor(loader): make read budget configurable Follow recommendations from https://github.com/helm/helm/pull/31748#discussion_r3058581419 Signed-off-by: Benoit Tigeot --- internal/chart/v3/loader/directory.go | 8 +++-- internal/chart/v3/loader/load_test.go | 6 +--- pkg/chart/loader/archive/budget.go | 45 +++++++++++++++++-------- pkg/chart/loader/archive/budget_test.go | 30 +++++++++-------- pkg/chart/v2/loader/directory.go | 8 +++-- pkg/chart/v2/loader/load_test.go | 6 +--- 6 files changed, 61 insertions(+), 42 deletions(-) diff --git a/internal/chart/v3/loader/directory.go b/internal/chart/v3/loader/directory.go index bc9a23ce1..3f7e8e77d 100644 --- a/internal/chart/v3/loader/directory.go +++ b/internal/chart/v3/loader/directory.go @@ -43,6 +43,10 @@ func (l DirLoader) Load() (*chart.Chart, error) { // // This loads charts only from directories. func LoadDir(dir string) (*chart.Chart, error) { + return loadDir(dir, archive.MaxDecompressedChartSize) +} + +func loadDir(dir string, budget int64) (*chart.Chart, error) { topdir, err := filepath.Abs(dir) if err != nil { return nil, err @@ -64,7 +68,7 @@ func LoadDir(dir string) (*chart.Chart, error) { files := []*archive.BufferedFile{} topdir += string(filepath.Separator) - remaining := archive.MaxDecompressedChartSize + budgetReader := archive.NewBudgetedReader(budget) walk := func(name string, fi os.FileInfo, err error) error { n := strings.TrimPrefix(name, topdir) @@ -101,7 +105,7 @@ func LoadDir(dir string) (*chart.Chart, error) { return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) } - data, err := archive.ReadFileWithBudget(name, fi.Size(), &remaining) + data, err := budgetReader.ReadFileWithBudget(name, fi.Size()) if err != nil { return fmt.Errorf("error reading %s: %w", n, err) } diff --git a/internal/chart/v3/loader/load_test.go b/internal/chart/v3/loader/load_test.go index 7d5e237ff..be5b3701d 100644 --- a/internal/chart/v3/loader/load_test.go +++ b/internal/chart/v3/loader/load_test.go @@ -52,11 +52,7 @@ func TestLoadDir(t *testing.T) { } func TestLoadDirExceedsBudget(t *testing.T) { - orig := archive.MaxDecompressedChartSize - archive.MaxDecompressedChartSize = 1 // 1 byte budget - defer func() { archive.MaxDecompressedChartSize = orig }() - - _, err := LoadDir("testdata/frobnitz") + _, err := loadDir("testdata/frobnitz", 1) if err == nil { t.Fatal("expected error when chart directory exceeds budget") } diff --git a/pkg/chart/loader/archive/budget.go b/pkg/chart/loader/archive/budget.go index a820dec47..fc5808580 100644 --- a/pkg/chart/loader/archive/budget.go +++ b/pkg/chart/loader/archive/budget.go @@ -17,22 +17,34 @@ limitations under the License. package archive import ( - "errors" "fmt" "io" + "math" "os" ) -// ReadFileWithBudget reads a file and decrements remaining by the bytes read. -// It returns an error if the total would exceed MaxDecompressedChartSize. +// budgetedReader tracks cumulative file reads against a size limit. +type budgetedReader struct { + max int64 + remaining int64 +} + +// newBudgetedReader creates a new budgetedReader with the given maximum decompressed chart size. +// The remaining budget is initialized to the maximum size. +func NewBudgetedReader(max int64) *budgetedReader { + return &budgetedReader{ + max: max, + remaining: max, + } +} + +// readFileWithBudget reads a file and decrements remaining by the bytes read. +// It returns an error if the total would exceed the maximum decompressed chart size. // The read is capped via io.LimitReader so a file that grows between stat // and read cannot cause unbounded memory allocation. -func ReadFileWithBudget(path string, size int64, remaining *int64) ([]byte, error) { - if remaining == nil { - return nil, errors.New("remaining budget must not be nil") - } - if size > *remaining { - return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) +func (r *budgetedReader) ReadFileWithBudget(path string, size int64) ([]byte, error) { + if size > r.remaining { + return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", r.max) } f, err := os.Open(path) @@ -41,17 +53,22 @@ func ReadFileWithBudget(path string, size int64, remaining *int64) ([]byte, erro } defer f.Close() - // Read at most *remaining+1 bytes so we can detect over-budget without + // Read at most r.remaining+1 bytes so we can detect over-budget without // allocating unbounded memory if the file grew since stat. - data, err := io.ReadAll(io.LimitReader(f, *remaining+1)) + // Clamp to avoid int64 overflow when r.remaining is near math.MaxInt64. + limit := r.remaining + if limit < math.MaxInt64 { + limit++ + } + data, err := io.ReadAll(io.LimitReader(f, limit)) if err != nil { return nil, err } - if int64(len(data)) > *remaining { - return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) + if int64(len(data)) > r.remaining { + return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", r.max) } - *remaining -= int64(len(data)) + r.remaining -= int64(len(data)) return data, nil } diff --git a/pkg/chart/loader/archive/budget_test.go b/pkg/chart/loader/archive/budget_test.go index d8b01da97..877664f19 100644 --- a/pkg/chart/loader/archive/budget_test.go +++ b/pkg/chart/loader/archive/budget_test.go @@ -35,8 +35,6 @@ func TestReadFileWithBudget(t *testing.T) { return p } - expectedErr := fmt.Sprintf("chart exceeds maximum decompressed size of %d bytes", MaxDecompressedChartSize) - tcs := []struct { name string check func(t *testing.T) @@ -50,17 +48,18 @@ func TestReadFileWithBudget(t *testing.T) { if err != nil { t.Fatalf("failed to stat %s: %v", p, err) } - remaining := int64(1000) + max := int64(1000) - data, err := ReadFileWithBudget(p, fi.Size(), &remaining) + br := NewBudgetedReader(max) + data, err := br.ReadFileWithBudget(p, fi.Size()) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(data) != 100 { t.Fatalf("expected 100 bytes, got %d", len(data)) } - if remaining != 900 { - t.Fatalf("expected remaining=900, got %d", remaining) + if br.remaining != 900 { + t.Fatalf("expected remaining=900, got %d", br.remaining) } }, }, @@ -73,17 +72,19 @@ func TestReadFileWithBudget(t *testing.T) { if err != nil { t.Fatalf("failed to stat %s: %v", p, err) } - remaining := int64(100) + max := int64(100) - _, err = ReadFileWithBudget(p, fi.Size(), &remaining) + br := NewBudgetedReader(max) + _, err = br.ReadFileWithBudget(p, fi.Size()) if err == nil { t.Fatal("expected error for file exceeding budget") } + expectedErr := fmt.Sprintf("chart exceeds maximum decompressed size of %d bytes", max) if err.Error() != expectedErr { t.Fatalf("expected %q, got %q", expectedErr, err.Error()) } - if remaining != 100 { - t.Fatalf("budget should not change on rejection, got %d", remaining) + if br.remaining != 100 { + t.Fatalf("budget should not change on rejection, got %d", br.remaining) } }, }, @@ -93,18 +94,19 @@ func TestReadFileWithBudget(t *testing.T) { t.Helper() remaining := int64(250) + br := NewBudgetedReader(remaining) for i := range 3 { p := writeFile(t, fmt.Sprintf("f%d.txt", i), 80) fi, err := os.Stat(p) if err != nil { t.Fatalf("failed to stat %s: %v", p, err) } - if _, err := ReadFileWithBudget(p, fi.Size(), &remaining); err != nil { + if _, err := br.ReadFileWithBudget(p, fi.Size()); err != nil { t.Fatalf("read %d: unexpected error: %v", i, err) } } - if remaining != 10 { - t.Fatalf("expected remaining=10, got %d", remaining) + if br.remaining != 10 { + t.Fatalf("expected remaining=10, got %d", br.remaining) } p := writeFile(t, "over.txt", 20) @@ -112,7 +114,7 @@ func TestReadFileWithBudget(t *testing.T) { if err != nil { t.Fatalf("failed to stat %s: %v", p, err) } - _, err = ReadFileWithBudget(p, fi.Size(), &remaining) + _, err = br.ReadFileWithBudget(p, fi.Size()) if err == nil { t.Fatal("expected error when cumulative reads exceed budget") } diff --git a/pkg/chart/v2/loader/directory.go b/pkg/chart/v2/loader/directory.go index da6b275d5..d54fbd3ed 100644 --- a/pkg/chart/v2/loader/directory.go +++ b/pkg/chart/v2/loader/directory.go @@ -43,6 +43,10 @@ func (l DirLoader) Load() (*chart.Chart, error) { // // This loads charts only from directories. func LoadDir(dir string) (*chart.Chart, error) { + return loadDir(dir, archive.MaxDecompressedChartSize) +} + +func loadDir(dir string, budget int64) (*chart.Chart, error) { topdir, err := filepath.Abs(dir) if err != nil { return nil, err @@ -64,7 +68,7 @@ func LoadDir(dir string) (*chart.Chart, error) { files := []*archive.BufferedFile{} topdir += string(filepath.Separator) - remaining := archive.MaxDecompressedChartSize + budgetReader := archive.NewBudgetedReader(budget) walk := func(name string, fi os.FileInfo, err error) error { n := strings.TrimPrefix(name, topdir) @@ -101,7 +105,7 @@ func LoadDir(dir string) (*chart.Chart, error) { return fmt.Errorf("cannot load irregular file %s as it has file mode type bits set", name) } - data, err := archive.ReadFileWithBudget(name, fi.Size(), &remaining) + data, err := budgetReader.ReadFileWithBudget(name, fi.Size()) if err != nil { return fmt.Errorf("error reading %s: %w", n, err) } diff --git a/pkg/chart/v2/loader/load_test.go b/pkg/chart/v2/loader/load_test.go index 5fd844f38..4d3321913 100644 --- a/pkg/chart/v2/loader/load_test.go +++ b/pkg/chart/v2/loader/load_test.go @@ -52,11 +52,7 @@ func TestLoadDir(t *testing.T) { } func TestLoadDirExceedsBudget(t *testing.T) { - orig := archive.MaxDecompressedChartSize - archive.MaxDecompressedChartSize = 1 // 1 byte budget - defer func() { archive.MaxDecompressedChartSize = orig }() - - _, err := LoadDir("testdata/frobnitz") + _, err := loadDir("testdata/frobnitz", 1) if err == nil { t.Fatal("expected error when chart directory exceeds budget") } From 378311fd38770e4e09dfb64366507fad9137a2cc Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Thu, 9 Apr 2026 21:54:19 +0200 Subject: [PATCH 7/8] fix(loader): export BudgetedReader for cross-package use Signed-off-by: Benoit Tigeot --- pkg/chart/loader/archive/budget.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg/chart/loader/archive/budget.go b/pkg/chart/loader/archive/budget.go index fc5808580..60a4bdec2 100644 --- a/pkg/chart/loader/archive/budget.go +++ b/pkg/chart/loader/archive/budget.go @@ -23,26 +23,26 @@ import ( "os" ) -// budgetedReader tracks cumulative file reads against a size limit. -type budgetedReader struct { +// BudgetedReader tracks cumulative file reads against a size limit. +type BudgetedReader struct { max int64 remaining int64 } -// newBudgetedReader creates a new budgetedReader with the given maximum decompressed chart size. -// The remaining budget is initialized to the maximum size. -func NewBudgetedReader(max int64) *budgetedReader { - return &budgetedReader{ +// NewBudgetedReader creates a BudgetedReader with the given maximum total size. +// The remaining budget is initialized to the maximum. +func NewBudgetedReader(max int64) *BudgetedReader { + return &BudgetedReader{ max: max, remaining: max, } } -// readFileWithBudget reads a file and decrements remaining by the bytes read. -// It returns an error if the total would exceed the maximum decompressed chart size. +// ReadFileWithBudget reads a file and decrements the remaining budget by the bytes read. +// It returns an error if the total would exceed the configured maximum. // The read is capped via io.LimitReader so a file that grows between stat // and read cannot cause unbounded memory allocation. -func (r *budgetedReader) ReadFileWithBudget(path string, size int64) ([]byte, error) { +func (r *BudgetedReader) ReadFileWithBudget(path string, size int64) ([]byte, error) { if size > r.remaining { return nil, fmt.Errorf("chart exceeds maximum decompressed size of %d bytes", r.max) } From 317882af3615381f87d8c02bb07dead5b03418c7 Mon Sep 17 00:00:00 2001 From: Benoit Tigeot Date: Thu, 9 Apr 2026 21:57:48 +0200 Subject: [PATCH 8/8] fix(loader): rename max param to avoid shadowing built-in Signed-off-by: Benoit Tigeot --- pkg/chart/loader/archive/budget.go | 6 +++--- pkg/chart/loader/archive/budget_test.go | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/chart/loader/archive/budget.go b/pkg/chart/loader/archive/budget.go index 60a4bdec2..3f9b90e4a 100644 --- a/pkg/chart/loader/archive/budget.go +++ b/pkg/chart/loader/archive/budget.go @@ -31,10 +31,10 @@ type BudgetedReader struct { // NewBudgetedReader creates a BudgetedReader with the given maximum total size. // The remaining budget is initialized to the maximum. -func NewBudgetedReader(max int64) *BudgetedReader { +func NewBudgetedReader(limit int64) *BudgetedReader { return &BudgetedReader{ - max: max, - remaining: max, + max: limit, + remaining: limit, } } diff --git a/pkg/chart/loader/archive/budget_test.go b/pkg/chart/loader/archive/budget_test.go index 877664f19..f37f78687 100644 --- a/pkg/chart/loader/archive/budget_test.go +++ b/pkg/chart/loader/archive/budget_test.go @@ -48,9 +48,9 @@ func TestReadFileWithBudget(t *testing.T) { if err != nil { t.Fatalf("failed to stat %s: %v", p, err) } - max := int64(1000) + limit := int64(1000) - br := NewBudgetedReader(max) + br := NewBudgetedReader(limit) data, err := br.ReadFileWithBudget(p, fi.Size()) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -72,14 +72,14 @@ func TestReadFileWithBudget(t *testing.T) { if err != nil { t.Fatalf("failed to stat %s: %v", p, err) } - max := int64(100) + limit := int64(100) - br := NewBudgetedReader(max) + br := NewBudgetedReader(limit) _, err = br.ReadFileWithBudget(p, fi.Size()) if err == nil { t.Fatal("expected error for file exceeding budget") } - expectedErr := fmt.Sprintf("chart exceeds maximum decompressed size of %d bytes", max) + expectedErr := fmt.Sprintf("chart exceeds maximum decompressed size of %d bytes", limit) if err.Error() != expectedErr { t.Fatalf("expected %q, got %q", expectedErr, err.Error()) }