diff --git a/internal/chart/v3/loader/archive.go b/internal/chart/v3/loader/archive.go index a9d4faf8f..d723e2731 100644 --- a/internal/chart/v3/loader/archive.go +++ b/internal/chart/v3/loader/archive.go @@ -27,16 +27,34 @@ import ( "helm.sh/helm/v4/pkg/chart/loader/archive" ) -// FileLoader loads a chart from a file -type FileLoader string +// FileLoader loads a chart from a file with embedded options +type FileLoader struct { + path string + opts archive.Options +} // Load loads a chart func (l FileLoader) Load() (*chart.Chart, error) { - return LoadFile(string(l)) + return LoadFile(l.path) +} + +// NewFileLoader creates a file loader with custom options +func NewFileLoader(path string, opts archive.Options) FileLoader { + return FileLoader{path: path, opts: opts} +} + +// NewDefaultFileLoader creates a file loader with default options +func NewDefaultFileLoader(path string) FileLoader { + return FileLoader{path: path, opts: archive.DefaultOptions} } -// LoadFile loads from an archive file. +// LoadFile loads from an archive file with default options func LoadFile(name string) (*chart.Chart, error) { + return LoadFileWithOptions(name, archive.DefaultOptions) +} + +// LoadFile loads from an archive file with the provided options +func LoadFileWithOptions(name string, opts archive.Options) (*chart.Chart, error) { if fi, err := os.Stat(name); err != nil { return nil, err } else if fi.IsDir() { @@ -54,7 +72,7 @@ func LoadFile(name string) (*chart.Chart, error) { return nil, err } - c, err := LoadArchive(raw) + c, err := LoadArchiveWithOptions(raw, opts) if err != nil { if errors.Is(err, gzip.ErrHeader) { return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %s)", name, err) @@ -65,7 +83,12 @@ func LoadFile(name string) (*chart.Chart, error) { // LoadArchive loads from a reader containing a compressed tar archive. func LoadArchive(in io.Reader) (*chart.Chart, error) { - files, err := archive.LoadArchiveFiles(in) + return LoadArchiveWithOptions(in, archive.DefaultOptions) +} + +// LoadArchive loads from a reader containing a compressed tar archive with the provided options. +func LoadArchiveWithOptions(in io.Reader, opts archive.Options) (*chart.Chart, error) { + files, err := archive.LoadArchiveFilesWithOptions(in, opts) if err != nil { return nil, err } diff --git a/internal/chart/v3/loader/directory.go b/internal/chart/v3/loader/directory.go index dfe3af3b2..413e7b34d 100644 --- a/internal/chart/v3/loader/directory.go +++ b/internal/chart/v3/loader/directory.go @@ -23,6 +23,8 @@ import ( "path/filepath" "strings" + "k8s.io/apimachinery/pkg/api/resource" + chart "helm.sh/helm/v4/internal/chart/v3" "helm.sh/helm/v4/internal/sympath" "helm.sh/helm/v4/pkg/chart/loader/archive" @@ -32,17 +34,39 @@ import ( var utf8bom = []byte{0xEF, 0xBB, 0xBF} // DirLoader loads a chart from a directory -type DirLoader string +type DirLoader struct { + path string + opts archive.Options +} + +// NewDirLoader creates a new directory loader with default options +func NewDefaultDirLoader(path string) DirLoader { + return DirLoader{path: path, opts: archive.DefaultOptions} +} + +// NewDirLoader creates a new directory loader with custom options +func NewDirLoader(path string, opts archive.Options) DirLoader { + return DirLoader{path: path, opts: opts} +} -// Load loads the chart +// Load loads the chart using default behavior for directories. func (l DirLoader) Load() (*chart.Chart, error) { - return LoadDir(string(l)) + return LoadDir(l.path) +} + +func (l DirLoader) LoadWithOptions() (*chart.Chart, error) { + return LoadDirWithOptions(l.path, l.opts) +} + +// LoadDir loads from a directory with default options +func LoadDir(dir string) (*chart.Chart, error) { + return LoadDirWithOptions(dir, archive.DefaultOptions) } // LoadDir loads from a directory. // // This loads charts only from directories. -func LoadDir(dir string) (*chart.Chart, error) { +func LoadDirWithOptions(dir string, opts archive.Options) (*chart.Chart, error) { topdir, err := filepath.Abs(dir) if err != nil { return nil, err @@ -100,8 +124,9 @@ 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) + if fi.Size() > opts.MaxDecompressedFileSize { + maxSize := resource.NewQuantity(opts.MaxDecompressedFileSize, resource.BinarySI) + return fmt.Errorf("chart file %q is larger than the maximum file size %s", fi.Name(), maxSize.String()) } data, err := os.ReadFile(name) diff --git a/internal/chart/v3/loader/load.go b/internal/chart/v3/loader/load.go index 373c4659f..1fd7b09fa 100644 --- a/internal/chart/v3/loader/load.go +++ b/internal/chart/v3/loader/load.go @@ -41,6 +41,18 @@ type ChartLoader interface { Load() (*chart.Chart, error) } +// LoadWithOptions takes a string name, resolves it to a file or directory, and loads it with custom options. +func LoadWithOptions(name string, opts archive.Options) (*chart.Chart, error) { + fi, err := os.Stat(name) + if err != nil { + return nil, err + } + if fi.IsDir() { + return LoadDirWithOptions(name, opts) + } + return LoadFileWithOptions(name, opts) +} + // Loader returns a new ChartLoader appropriate for the given chart name func Loader(name string) (ChartLoader, error) { fi, err := os.Stat(name) @@ -48,9 +60,9 @@ func Loader(name string) (ChartLoader, error) { return nil, err } if fi.IsDir() { - return DirLoader(name), nil + return NewDefaultDirLoader(name), nil } - return FileLoader(name), nil + return NewDefaultFileLoader(name), nil } // Load takes a string name, tries to resolve it to a file or directory, and then loads it. diff --git a/internal/chart/v3/util/expand.go b/internal/chart/v3/util/expand.go index 1a10fce3c..e1a99a0d8 100644 --- a/internal/chart/v3/util/expand.go +++ b/internal/chart/v3/util/expand.go @@ -30,9 +30,14 @@ import ( "helm.sh/helm/v4/pkg/chart/loader/archive" ) -// Expand uncompresses and extracts a chart into the specified directory. +// Expand uncompresses and extracts a chart into the specified directory using default options. func Expand(dir string, r io.Reader) error { - files, err := archive.LoadArchiveFiles(r) + return ExpandWithOptions(dir, r, archive.DefaultOptions) +} + +// ExpandWithOptions uncompresses and extracts a chart into the specified directory using the provided options. +func ExpandWithOptions(dir string, r io.Reader, opts archive.Options) error { + files, err := archive.LoadArchiveFilesWithOptions(r, opts) if err != nil { return err } @@ -83,12 +88,17 @@ func Expand(dir string, r io.Reader) error { return nil } -// ExpandFile expands the src file into the dest directory. +// ExpandFile expands the src file into the dest directory using default options. func ExpandFile(dest, src string) error { + return ExpandFileWithOptions(dest, src, archive.DefaultOptions) +} + +// ExpandFileWithOptions expands the src file into the dest directory using the provided options. +func ExpandFileWithOptions(dest, src string, opts archive.Options) error { h, err := os.Open(src) if err != nil { return err } defer h.Close() - return Expand(dest, h) + return ExpandWithOptions(dest, h, opts) } diff --git a/pkg/action/install.go b/pkg/action/install.go index ecf3ea340..a81d3c109 100644 --- a/pkg/action/install.go +++ b/pkg/action/install.go @@ -132,6 +132,10 @@ type Install struct { // Lock to control raceconditions when the process receives a SIGTERM Lock sync.Mutex goroutineCount atomic.Int32 + // MaxChartSize is the maximum size of a decompressed chart in bytes + MaxChartSize int64 + // MaxChartFileSize is the maximum size of a single file in a chart in bytes + MaxChartFileSize int64 } // ChartPathOptions captures common options used for controlling chart paths diff --git a/pkg/action/pull.go b/pkg/action/pull.go index dd051167b..b2f56f4f6 100644 --- a/pkg/action/pull.go +++ b/pkg/action/pull.go @@ -22,6 +22,7 @@ import ( "path/filepath" "strings" + "helm.sh/helm/v4/pkg/chart/loader/archive" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/downloader" @@ -44,6 +45,10 @@ type Pull struct { UntarDir string DestDir string cfg *Configuration + // MaxChartSize is the maximum decompressed size of a chart in bytes + MaxChartSize int64 + // MaxChartFileSize is the maximum size of a single file in a chart in bytes + MaxChartFileSize int64 } type PullOpt func(*Pull) @@ -169,7 +174,14 @@ func (p *Pull) Run(chartRef string) (string, error) { return out.String(), fmt.Errorf("failed to untar: a file or directory with the name %s already exists", udCheck) } - return out.String(), chartutil.ExpandFile(ud, saved) + opts := archive.DefaultOptions + if p.MaxChartSize > 0 { + opts.MaxDecompressedChartSize = p.MaxChartSize + } + if p.MaxChartFileSize > 0 { + opts.MaxDecompressedFileSize = p.MaxChartFileSize + } + return out.String(), chartutil.ExpandFileWithOptions(ud, saved, opts) } return out.String(), nil } diff --git a/pkg/action/upgrade.go b/pkg/action/upgrade.go index 13d28fd4d..1c1bcd227 100644 --- a/pkg/action/upgrade.go +++ b/pkg/action/upgrade.go @@ -129,6 +129,10 @@ type Upgrade struct { EnableDNS bool // TakeOwnership will skip the check for helm annotations and adopt all existing resources. TakeOwnership bool + // MaxChartSize is the maximum decompressed size of a chart in bytes + MaxChartSize int64 + // MaxChartFileSize is the maximum size of a single file in a chart in bytes + MaxChartFileSize int64 } type resultMessage struct { diff --git a/pkg/chart/loader/archive/archive.go b/pkg/chart/loader/archive/archive.go index e98f5c333..8c224c8b7 100644 --- a/pkg/chart/loader/archive/archive.go +++ b/pkg/chart/loader/archive/archive.go @@ -30,16 +30,9 @@ import ( "regexp" "strings" "time" -) - -// MaxDecompressedChartSize is the maximum size of a chart archive that will be -// decompressed. This is the decompressed size of all the files. -// 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 + "k8s.io/apimachinery/pkg/api/resource" +) var drivePathPattern = regexp.MustCompile(`^[a-zA-Z]:/`) @@ -52,10 +45,33 @@ type BufferedFile struct { Data []byte } +// Options controls archive loading limits. +type Options struct { + // MaxDecompressedChartSize is the maximum size of a chart archive that will be + // decompressed. This is the decompressed size of all the files. + MaxDecompressedChartSize int64 + // 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 int64 +} + +// DefaultOptions provides the default size limits used when loading archives. +var DefaultOptions = Options{ + MaxDecompressedChartSize: 100 * 1024 * 1024, // 100 MiB + MaxDecompressedFileSize: 5 * 1024 * 1024, // 5 MiB +} + // LoadArchiveFiles reads in files out of an archive into memory. This function // performs important path security checks and should always be used before -// expanding a tarball +// expanding a tarball. It use default options. func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { + return LoadArchiveFilesWithOptions(in, DefaultOptions) +} + +// LoadArchiveFiles reads in files out of an archive into memory. This function +// performs important path security checks and should always be used before +// expanding a tarball. It uses the provided options. +func LoadArchiveFilesWithOptions(in io.Reader, opts Options) ([]*BufferedFile, error) { unzipped, err := gzip.NewReader(in) if err != nil { return nil, err @@ -64,7 +80,7 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { files := []*BufferedFile{} tr := tar.NewReader(unzipped) - remainingSize := MaxDecompressedChartSize + remainingSize := opts.MaxDecompressedChartSize for { b := bytes.NewBuffer(nil) hd, err := tr.Next() @@ -125,11 +141,13 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { } if hd.Size > remainingSize { - return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize) + maxSize := resource.NewQuantity(opts.MaxDecompressedChartSize, resource.BinarySI) + return nil, fmt.Errorf("decompressed chart is larger than the maximum size %s", maxSize.String()) } - if hd.Size > MaxDecompressedFileSize { - return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %d", hd.Name, MaxDecompressedFileSize) + if hd.Size > opts.MaxDecompressedFileSize { + maxSize := resource.NewQuantity(opts.MaxDecompressedFileSize, resource.BinarySI) + return nil, fmt.Errorf("decompressed chart file %q is larger than the maximum file size %s", hd.Name, maxSize.String()) } limitedReader := io.LimitReader(tr, remainingSize) @@ -145,7 +163,8 @@ func LoadArchiveFiles(in io.Reader) ([]*BufferedFile, error) { // is the one that goes over the limit. It assumes the Size stored in the tar header // is correct, something many applications do. if bytesWritten < hd.Size || remainingSize <= 0 { - return nil, fmt.Errorf("decompressed chart is larger than the maximum size %d", MaxDecompressedChartSize) + maxSize := resource.NewQuantity(opts.MaxDecompressedChartSize, resource.BinarySI) + return nil, fmt.Errorf("decompressed chart is larger than the maximum size %s", maxSize.String()) } data := bytes.TrimPrefix(b.Bytes(), utf8bom) diff --git a/pkg/chart/loader/load.go b/pkg/chart/loader/load.go index 3fd381825..8e2b685a0 100644 --- a/pkg/chart/loader/load.go +++ b/pkg/chart/loader/load.go @@ -37,6 +37,7 @@ import ( // ChartLoader loads a chart. type ChartLoader interface { Load() (chart.Charter, error) + LoadWithOptions() (chart.Charter, error) } // Loader returns a new ChartLoader appropriate for the given chart name @@ -46,9 +47,31 @@ func Loader(name string) (ChartLoader, error) { return nil, err } if fi.IsDir() { - return DirLoader(name), nil + return NewDefaultDirLoader(name), nil } - return FileLoader(name), nil + return NewDefaultFileLoader(name), nil +} + +// WithOptions returns a new ChartLoader appropriate for the given chart name +// with the provided options. +func WithOptions(name string, opts archive.Options) (ChartLoader, error) { + fi, err := os.Stat(name) + if err != nil { + return nil, err + } + if fi.IsDir() { + return NewDirLoader(name, opts), nil + } + return NewFileLoader(name, opts), nil +} + +// LoadWithOptions takes a string name, resolves it to a file or directory, and loads it with custom options. +func LoadWithOptions(name string, opts archive.Options) (chart.Charter, error) { + l, err := WithOptions(name, opts) + if err != nil { + return nil, err + } + return l.LoadWithOptions() } // Load takes a string name, tries to resolve it to a file or directory, and then loads it. @@ -67,14 +90,36 @@ func Load(name string) (chart.Charter, error) { } // DirLoader loads a chart from a directory -type DirLoader string +type DirLoader struct { + path string + opts archive.Options +} + +// NewDefaultDirLoader creates a new directory loader with default options +func NewDefaultDirLoader(path string) DirLoader { + return DirLoader{path: path, opts: archive.DefaultOptions} +} + +// NewDirLoader creates a new directory loader with custom options +func NewDirLoader(path string, opts archive.Options) DirLoader { + return DirLoader{path: path, opts: opts} +} // Load loads the chart func (l DirLoader) Load() (chart.Charter, error) { - return LoadDir(string(l)) + return LoadDir(l.path) +} + +// LoadWithOptions loads the chart with custom options +func (l DirLoader) LoadWithOptions() (chart.Charter, error) { + return LoadDirWithOptions(l.path, l.opts) } func LoadDir(dir string) (chart.Charter, error) { + return LoadDirWithOptions(dir, archive.DefaultOptions) +} + +func LoadDirWithOptions(dir string, opts archive.Options) (chart.Charter, error) { topdir, err := filepath.Abs(dir) if err != nil { return nil, err @@ -94,24 +139,46 @@ func LoadDir(dir string) (chart.Charter, error) { switch c.APIVersion { case c2.APIVersionV1, c2.APIVersionV2, "": - return c2load.Load(dir) + return c2load.LoadWithOptions(dir, opts) case c3.APIVersionV3: - return c3load.Load(dir) + return c3load.LoadWithOptions(dir, opts) default: return nil, errors.New("unsupported chart version") } } -// FileLoader loads a chart from a file -type FileLoader string +// FileLoader with embedded options +type FileLoader struct { + path string + opts archive.Options +} + +// NewFileLoader creates a file loader with custom options +func NewFileLoader(path string, opts archive.Options) FileLoader { + return FileLoader{path: path, opts: opts} +} + +// NewDefaultFileLoader creates a file loader with default options +func NewDefaultFileLoader(path string) FileLoader { + return FileLoader{path: path, opts: archive.DefaultOptions} +} -// Load loads a chart +// Load loads a chart with default options func (l FileLoader) Load() (chart.Charter, error) { - return LoadFile(string(l)) + return LoadFileWithOptions(l.path, archive.DefaultOptions) +} + +// LoadWithOptions loads a chart with custom options +func (l FileLoader) LoadWithOptions() (chart.Charter, error) { + return LoadFileWithOptions(l.path, l.opts) } func LoadFile(name string) (chart.Charter, error) { + return LoadFileWithOptions(name, archive.DefaultOptions) +} + +func LoadFileWithOptions(name string, opts archive.Options) (chart.Charter, error) { if fi, err := os.Stat(name); err != nil { return nil, err } else if fi.IsDir() { @@ -129,12 +196,12 @@ func LoadFile(name string) (chart.Charter, error) { return nil, err } - files, err := archive.LoadArchiveFiles(raw) + files, err := archive.LoadArchiveFilesWithOptions(raw, opts) if err != nil { if errors.Is(err, gzip.ErrHeader) { return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) } - return nil, errors.New("unable to load chart archive") + return nil, fmt.Errorf("unable to load chart archive: %w", err) } for _, f := range files { @@ -145,9 +212,9 @@ func LoadFile(name string) (chart.Charter, error) { } switch c.APIVersion { case c2.APIVersionV1, c2.APIVersionV2, "": - return c2load.Load(name) + return c2load.LoadWithOptions(name, opts) case c3.APIVersionV3: - return c3load.Load(name) + return c3load.LoadWithOptions(name, opts) default: return nil, errors.New("unsupported chart version") } diff --git a/pkg/chart/v2/loader/archive.go b/pkg/chart/v2/loader/archive.go index c6885e125..0cff87caf 100644 --- a/pkg/chart/v2/loader/archive.go +++ b/pkg/chart/v2/loader/archive.go @@ -37,6 +37,11 @@ func (l FileLoader) Load() (*chart.Chart, error) { // LoadFile loads from an archive file. func LoadFile(name string) (*chart.Chart, error) { + return LoadFileWithOptions(name, archive.DefaultOptions) +} + +// LoadFileWithOptions loads from an archive file using the provided options. +func LoadFileWithOptions(name string, opts archive.Options) (*chart.Chart, error) { if fi, err := os.Stat(name); err != nil { return nil, err } else if fi.IsDir() { @@ -54,7 +59,7 @@ func LoadFile(name string) (*chart.Chart, error) { return nil, err } - c, err := LoadArchive(raw) + c, err := LoadArchiveWithOptions(raw, opts) if err != nil { if errors.Is(err, gzip.ErrHeader) { return nil, fmt.Errorf("file '%s' does not appear to be a valid chart file (details: %w)", name, err) @@ -65,10 +70,14 @@ func LoadFile(name string) (*chart.Chart, error) { // LoadArchive loads from a reader containing a compressed tar archive. func LoadArchive(in io.Reader) (*chart.Chart, error) { - files, err := archive.LoadArchiveFiles(in) + return LoadArchiveWithOptions(in, archive.DefaultOptions) +} + +// LoadArchiveWithOptions loads from a reader containing a compressed tar archive using the provided options. +func LoadArchiveWithOptions(in io.Reader, opts archive.Options) (*chart.Chart, error) { + files, err := archive.LoadArchiveFilesWithOptions(in, opts) if err != nil { return nil, err } - return LoadFiles(files) } diff --git a/pkg/chart/v2/loader/directory.go b/pkg/chart/v2/loader/directory.go index 82578d924..468ad5a6d 100644 --- a/pkg/chart/v2/loader/directory.go +++ b/pkg/chart/v2/loader/directory.go @@ -23,6 +23,8 @@ import ( "path/filepath" "strings" + "k8s.io/apimachinery/pkg/api/resource" + "helm.sh/helm/v4/internal/sympath" "helm.sh/helm/v4/pkg/chart/loader/archive" chart "helm.sh/helm/v4/pkg/chart/v2" @@ -43,6 +45,11 @@ func (l DirLoader) Load() (*chart.Chart, error) { // // This loads charts only from directories. func LoadDir(dir string) (*chart.Chart, error) { + return LoadDirWithOptions(dir, archive.DefaultOptions) +} + +// LoadDirWithOptions loads from a directory using the provided options. +func LoadDirWithOptions(dir string, opts archive.Options) (*chart.Chart, error) { topdir, err := filepath.Abs(dir) if err != nil { return nil, err @@ -100,8 +107,9 @@ 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) + if fi.Size() > opts.MaxDecompressedFileSize { + maxSize := resource.NewQuantity(opts.MaxDecompressedFileSize, resource.BinarySI) + return fmt.Errorf("chart file %q is larger than the maximum file size %s", fi.Name(), maxSize.String()) } data, err := os.ReadFile(name) diff --git a/pkg/chart/v2/loader/load.go b/pkg/chart/v2/loader/load.go index d466e247c..e89452f25 100644 --- a/pkg/chart/v2/loader/load.go +++ b/pkg/chart/v2/loader/load.go @@ -61,11 +61,19 @@ func Loader(name string) (ChartLoader, error) { // If a .helmignore file is present, the directory loader will skip loading any files // matching it. But .helmignore is not evaluated when reading out of an archive. func Load(name string) (*chart.Chart, error) { - l, err := Loader(name) + return LoadWithOptions(name, archive.DefaultOptions) +} + +// LoadWithOptions takes a string name, resolves it to a file or directory, and loads it with custom options. +func LoadWithOptions(name string, opts archive.Options) (*chart.Chart, error) { + fi, err := os.Stat(name) if err != nil { return nil, err } - return l.Load() + if fi.IsDir() { + return LoadDirWithOptions(name, opts) + } + return LoadFileWithOptions(name, opts) } // LoadFiles loads from in-memory files. diff --git a/pkg/chart/v2/util/expand.go b/pkg/chart/v2/util/expand.go index 077dfbf38..a6308a0a9 100644 --- a/pkg/chart/v2/util/expand.go +++ b/pkg/chart/v2/util/expand.go @@ -30,9 +30,14 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" ) -// Expand uncompresses and extracts a chart into the specified directory. +// Expand uncompresses and extracts a chart into the specified directory using default options. func Expand(dir string, r io.Reader) error { - files, err := archive.LoadArchiveFiles(r) + return ExpandWithOptions(dir, r, archive.DefaultOptions) +} + +// ExpandWithOptions uncompresses and extracts a chart into the specified directory using the provided options. +func ExpandWithOptions(dir string, r io.Reader, opts archive.Options) error { + files, err := archive.LoadArchiveFilesWithOptions(r, opts) if err != nil { return err } @@ -83,12 +88,17 @@ func Expand(dir string, r io.Reader) error { return nil } -// ExpandFile expands the src file into the dest directory. +// ExpandFile expands the src file into the dest directory using default options. func ExpandFile(dest, src string) error { + return ExpandFileWithOptions(dest, src, archive.DefaultOptions) +} + +// ExpandFileWithOptions expands the src file into the dest directory using the provided options. +func ExpandFileWithOptions(dest, src string, opts archive.Options) error { h, err := os.Open(src) if err != nil { return err } defer h.Close() - return Expand(dest, h) + return ExpandWithOptions(dest, h, opts) } diff --git a/pkg/cli/environment.go b/pkg/cli/environment.go index 106d24336..2d30770a1 100644 --- a/pkg/cli/environment.go +++ b/pkg/cli/environment.go @@ -25,12 +25,14 @@ package cli import ( "fmt" + "log/slog" "net/http" "os" "strconv" "strings" "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/api/resource" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" @@ -93,6 +95,10 @@ type EnvSettings struct { ColorMode string // ContentCache is the location where cached charts are stored ContentCache string + // MaxChartSize is the maximum size of a decompressed chart in bytes + MaxChartSize int64 + // MaxChartFileSize is the maximum size of a single file in a chart in bytes + MaxChartFileSize int64 } func New() *EnvSettings { @@ -115,6 +121,8 @@ func New() *EnvSettings { BurstLimit: envIntOr("HELM_BURST_LIMIT", defaultBurstLimit), QPS: envFloat32Or("HELM_QPS", defaultQPS), ColorMode: envColorMode(), + MaxChartSize: envInt64OrQuantityBytes("HELM_MAX_CHART_SIZE", 100*1024*1024), // 100 MiB + MaxChartFileSize: envInt64OrQuantityBytes("HELM_MAX_FILE_SIZE", 5*1024*1024), // 5 MiB } env.Debug, _ = strconv.ParseBool(os.Getenv("HELM_DEBUG")) @@ -214,6 +222,92 @@ func envFloat32Or(name string, def float32) float32 { return float32(ret) } +// parseByteSizeOrInt64 parses a string as either a Kubernetes Quantity or plain int64, +// specifically for byte sizes. Returns the parsed value in bytes +func parseByteSizeOrInt64(s string) (int64, error) { + s = strings.TrimSpace(s) + + // Try parsing as Kubernetes Quantity first + if q, err := resource.ParseQuantity(s); err == nil { + if v, ok := q.AsInt64(); ok { + return v, nil + } + f := q.AsApproximateFloat64() + // Reject quantities that evaluate to less than 1 byte (e.g. "1m" -> 0.001) + // because file sizes must be whole bytes. Treat those as parsing errors. + if f < 1 { + // Provide a helpful message if the user tried to use "m" (milli) suffix + if strings.HasSuffix(strings.ToLower(s), "m") && !strings.HasSuffix(s, "M") { + return 0, fmt.Errorf("quantity %q uses 'm' (milli) suffix which represents 0.001; please use IEC values like Ki, Mi, Gi", s) + } + return 0, fmt.Errorf("quantity %q is too small (less than 1 byte)", s) + } + if f >= float64(^uint64(0)>>1) { + return 0, fmt.Errorf("quantity %q is too large to fit in int64", s) + } + return int64(f), nil + } + + // Fallback to plain int64 + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid value %q (expected int or k8s Quantity like 512Mi)", s) + } + return v, nil +} + +// Tries to parse as a k8s Quantity first, falls back to plain int64 parsing. +func envInt64OrQuantityBytes(name string, def int64) int64 { + if name == "" { + return def + } + envVal := os.Getenv(name) + if envVal == "" { + return def + } + + v, err := parseByteSizeOrInt64(envVal) + if err != nil { + defQuantity := resource.NewQuantity(def, resource.BinarySI) + slog.Warn(err.Error() + fmt.Sprintf(": using default %s", defQuantity.String())) + return def + } + return v +} + +// QuantityBytesValue is a custom flag type that accepts both plain int64 and k8s Quantity formats +type QuantityBytesValue struct { + value *int64 +} + +// NewQuantityBytesValue creates a new QuantityBytesValue flag with a pointer to an int64 +func NewQuantityBytesValue(p *int64) *QuantityBytesValue { + return &QuantityBytesValue{value: p} +} + +// Set parses the input string as either a Kubernetes Quantity or plain int64 +func (q *QuantityBytesValue) Set(s string) error { + v, err := parseByteSizeOrInt64(s) + if err != nil { + return err + } + *q.value = v + return nil +} + +// String returns the string representation of the value +func (q *QuantityBytesValue) String() string { + if q.value == nil { + return "0" + } + return strconv.FormatInt(*q.value, 10) +} + +// Type returns the type name for help messages +func (q *QuantityBytesValue) Type() string { + return "quantity" +} + func envCSV(name string) (ls []string) { trimmed := strings.Trim(os.Getenv(name), ", ") if trimmed != "" { @@ -255,6 +349,8 @@ func (s *EnvSettings) EnvVars() map[string]string { "HELM_MAX_HISTORY": strconv.Itoa(s.MaxHistory), "HELM_BURST_LIMIT": strconv.Itoa(s.BurstLimit), "HELM_QPS": strconv.FormatFloat(float64(s.QPS), 'f', 2, 32), + "HELM_MAX_CHART_SIZE": strconv.FormatInt(s.MaxChartSize, 10), + "HELM_MAX_FILE_SIZE": strconv.FormatInt(s.MaxChartFileSize, 10), // broken, these are populated from helm flags and not kubeconfig. "HELM_KUBECONTEXT": s.KubeContext, diff --git a/pkg/cli/environment_test.go b/pkg/cli/environment_test.go index 52326eeff..02604333a 100644 --- a/pkg/cli/environment_test.go +++ b/pkg/cli/environment_test.go @@ -242,6 +242,142 @@ func TestEnvOrBool(t *testing.T) { } } +func TestEnvInt64OrQuantityBytes(t *testing.T) { + envName := "TEST_ENV_INT64" + + tests := []struct { + name string + env string + val string + def int64 + expected int64 + }{ + { + name: "empty env name uses default", + env: "", + val: "999", + def: 100, + expected: 100, + }, + { + name: "env set with valid int64", + env: envName, + val: "12345", + def: 100, + expected: 12345, + }, + { + name: "env fails parsing with default", + env: envName, + val: "NOT_A_NUMBER", + def: 100, + expected: 100, + }, + { + name: "env empty string with default", + env: envName, + val: "", + def: 200, + expected: 200, + }, + + // Quantity cases (bytes) + { + name: "quantity Mi", + env: envName, + val: "512Mi", + def: 100, + expected: 512 * 1024 * 1024, + }, + { + name: "quantity Gi", + env: envName, + val: "2Gi", + def: 100, + expected: 2 * 1024 * 1024 * 1024, + }, + { + name: "quantity Ki", + env: envName, + val: "4096Ki", + def: 100, + expected: 4096 * 1024, + }, + { + name: "decimal SI 1G (base10)", + env: envName, + val: "1G", + def: 100, + // 1G in decimal SI is 1,000,000,000 bytes + expected: 1_000_000_000, + }, + { + name: "decimal SI 500M (base10)", + env: envName, + val: "500M", + def: 100, + expected: 500_000_000, + }, + { + name: "lowercase suffix returns default with error message", + env: envName, + val: "1gi", + def: 100, + expected: 100, // Returns default but prints error about uppercase requirement + }, + { + name: "suffix Mb rejected", + env: envName, + val: "1000Mb", + def: 100, + expected: 100, // Returns default but prints error about 'Mb' being invalid + }, + { + name: "suffix mo rejected", + env: envName, + val: "1000mo", + def: 100, + expected: 100, // Returns default but prints error about 'mo' being invalid + }, + { + name: "suffix m rejected (milli)", + env: envName, + val: "10m", + def: 100, + expected: 100, // Returns default but prints error about 'm' being invalid + }, + { + name: "whitespace trimmed", + env: envName, + val: " 256Mi ", + def: 100, + expected: 256 * 1024 * 1024, + }, + { + name: "too large to fit in int64 returns default", + env: envName, + // ~9.22e18 is max int64; use larger than that to trigger overflow handling. + val: "10000000000Gi", // 10,000,000,000 * 1024^3 bytes ≈ 1.07e22 + def: 1234, + expected: 1234, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear previous value to avoid bleed between tests + t.Setenv(envName, "") + if tt.env != "" { + t.Setenv(tt.env, tt.val) + } + actual := envInt64OrQuantityBytes(tt.env, tt.def) + if actual != tt.expected { + t.Errorf("expected result %d, got %d (env=%q val=%q def=%d)", tt.expected, actual, tt.env, tt.val, tt.def) + } + }) + } +} + func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { defer resetEnv()() @@ -257,6 +393,60 @@ func TestUserAgentHeaderInK8sRESTClientConfig(t *testing.T) { } } +func TestQuantityBytesValue(t *testing.T) { + // This test only verifies that the pflag.Value wrapper correctly propagates + // values and errors. Comprehensive parsing logic is tested in TestEnvInt64OrQuantityBytes. + tests := []struct { + name string + input string + expected int64 + expectError bool + }{ + { + name: "valid quantity sets value", + input: "256Mi", + expected: 256 * 1024 * 1024, + }, + { + name: "invalid value propagates error", + input: "not-a-number", + expectError: true, + }, + { + name: "Mb suffix rejected", + input: "1Mb", + expectError: true, + }, + { + name: "m suffix rejected", + input: "1m", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var val int64 + qv := NewQuantityBytesValue(&val) + + err := qv.Set(tt.input) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if val != tt.expected { + t.Errorf("expected %d, got %d", tt.expected, val) + } + } + }) + } +} + func resetEnv() func() { origEnv := os.Environ() diff --git a/pkg/cmd/install.go b/pkg/cmd/install.go index d36cd9e34..ff46baa8b 100644 --- a/pkg/cmd/install.go +++ b/pkg/cmd/install.go @@ -33,6 +33,8 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/chart" "helm.sh/helm/v4/pkg/chart/loader" + "helm.sh/helm/v4/pkg/chart/loader/archive" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" @@ -131,6 +133,8 @@ charts in a repository, use 'helm search'. func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewInstall(cfg) + client.MaxChartSize = settings.MaxChartSize + client.MaxChartFileSize = settings.MaxChartFileSize valueOpts := &values.Options{} var outfmt output.Format @@ -179,6 +183,8 @@ func newInstallCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { addDryRunFlag(cmd) bindOutputFlag(cmd, &outfmt) bindPostRenderFlag(cmd, &client.PostRenderer, settings) + f.Var(cli.NewQuantityBytesValue(&client.MaxChartSize), "max-chart-size", "maximum size for a decompressed chart (e.g., 500Ki, 5Mi)") + f.Var(cli.NewQuantityBytesValue(&client.MaxChartFileSize), "max-file-size", "maximum size for a single file in a chart (e.g., 5Mi, 10Mi)") return cmd } @@ -256,8 +262,16 @@ func runInstall(args []string, client *action.Install, valueOpts *values.Options return nil, err } + opts := archive.DefaultOptions + if client.MaxChartSize > 0 { + opts.MaxDecompressedChartSize = client.MaxChartSize + } + if client.MaxChartFileSize > 0 { + opts.MaxDecompressedFileSize = client.MaxChartFileSize + } + // Check chart dependencies to make sure all are present in /charts - chartRequested, err := loader.Load(cp) + chartRequested, err := loader.LoadWithOptions(cp, opts) if err != nil { return nil, err } diff --git a/pkg/cmd/install_test.go b/pkg/cmd/install_test.go index f0f12e4f7..0ade6ce86 100644 --- a/pkg/cmd/install_test.go +++ b/pkg/cmd/install_test.go @@ -23,6 +23,7 @@ import ( "path/filepath" "testing" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/repo/v1/repotest" ) @@ -274,11 +275,67 @@ func TestInstall(t *testing.T) { wantError: true, golden: "output/install-hide-secret.txt", }, + { + name: "install with restricted max size", + cmd: "install too-big testdata/testcharts/compressedchart-0.1.0.tgz --max-chart-size=42", + wantError: true, + golden: "output/install-with-restricted-chart-size.txt", + }, } runTestCmd(t, tests) } +func TestInstallWithEnvVars(t *testing.T) { + tests := []struct { + name string + cmd string + envVars map[string]string + wantError bool + golden string + }{ + { + name: "install with HELM_MAX_CHART_SIZE env var with bytes", + cmd: "install too-big testdata/testcharts/compressedchart-0.1.0.tgz", + envVars: map[string]string{ + "HELM_MAX_CHART_SIZE": "42", + }, + wantError: true, + golden: "output/install-with-restricted-chart-size.txt", + }, + { + name: "install with HELM_MAX_FILE_SIZE env var with Quantity suffix", + cmd: "install test-max-file testdata/testcharts/bigchart-0.1.0.tgz", + envVars: map[string]string{ + "HELM_MAX_FILE_SIZE": "1Ki", + }, + wantError: true, + golden: "output/install-with-restricted-file-size.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer resetEnv()() + + for k, v := range tt.envVars { + t.Setenv(k, v) + } + // Reset settings to pick up env vars + settings = cli.New() + + test := cmdTestCase{ + name: tt.name, + cmd: tt.cmd, + golden: tt.golden, + wantError: tt.wantError, + } + + runTestCmd(t, []cmdTestCase{test}) + }) + } +} + func TestInstallOutputCompletion(t *testing.T) { outputFlagCompletionTest(t, "install") } diff --git a/pkg/cmd/pull.go b/pkg/cmd/pull.go index bb7a8d1c0..0a82877d5 100644 --- a/pkg/cmd/pull.go +++ b/pkg/cmd/pull.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "helm.sh/helm/v4/pkg/action" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cmd/require" ) @@ -45,6 +46,9 @@ result in an error, and the chart will not be saved locally. func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewPull(action.WithConfig(cfg)) + // Initialize from environment settings so they serve as defaults for the flags + client.MaxChartSize = settings.MaxChartSize + client.MaxChartFileSize = settings.MaxChartFileSize cmd := &cobra.Command{ Use: "pull [chart URL | repo/chartname] [...]", @@ -89,6 +93,8 @@ func newPullCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.VerifyLater, "prov", false, "fetch the provenance file, but don't perform verification") f.StringVar(&client.UntarDir, "untardir", ".", "if untar is specified, this flag specifies the name of the directory into which the chart is expanded") f.StringVarP(&client.DestDir, "destination", "d", ".", "location to write the chart. If this and untardir are specified, untardir is appended to this") + f.Var(cli.NewQuantityBytesValue(&client.MaxChartSize), "max-chart-size", "maximum size for a decompressed chart (e.g., 100Mi, 1Gi; default is 100Mi)") + f.Var(cli.NewQuantityBytesValue(&client.MaxChartFileSize), "max-file-size", "maximum size for a single file in a chart (e.g., 5Mi, 10Mi; default is 5Mi)") addChartPathOptionsFlags(f, &client.ChartPathOptions) err := cmd.RegisterFlagCompletionFunc("version", func(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/pkg/cmd/pull_test.go b/pkg/cmd/pull_test.go index 96631fe05..6d34d9f80 100644 --- a/pkg/cmd/pull_test.go +++ b/pkg/cmd/pull_test.go @@ -207,8 +207,15 @@ func TestPullCmd(t *testing.T) { { name: "Fail fetching OCI chart with version mismatch", args: fmt.Sprintf("oci://%s/u/ocitestuser/oci-dependent-chart:0.2.0 --version 0.1.0", ociSrv.RegistryURL), + wantError: true, wantErrorMsg: "chart reference and version mismatch: 0.1.0 is not 0.2.0", + failExpect: "chart reference and version mismatch", + }, + { + name: "Fail because of small max chart size", + args: "test/bigchart --untar --max-chart-size=3Ki", wantError: true, + wantErrorMsg: "decompressed chart is larger than the maximum size 3Ki", }, } diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 04ba91c1f..bbbd23878 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -84,6 +84,8 @@ Environment variables: | $HELM_QPS | set the Queries Per Second in cases where a high number of calls exceed the option for higher burst values | | $HELM_COLOR | set color output mode. Allowed values: never, always, auto (default: never) | | $NO_COLOR | set to any non-empty value to disable all colored output (overrides $HELM_COLOR) | +| $HELM_MAX_CHART_SIZE | set the maximum size in bytes for a decompressed chart (default: 100MiB, 0 means use default limit) | +| $HELM_MAX_FILE_SIZE | set the maximum size in bytes for a single file in a chart (default: 5MiB, 0 means use default limit) | Helm stores cache, configuration, and data based on the following configuration order: diff --git a/pkg/cmd/template.go b/pkg/cmd/template.go index cf68c6c46..e66d8cf04 100644 --- a/pkg/cmd/template.go +++ b/pkg/cmd/template.go @@ -35,6 +35,7 @@ import ( "helm.sh/helm/v4/pkg/action" "helm.sh/helm/v4/pkg/chart/common" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" releaseutil "helm.sh/helm/v4/pkg/release/v1/util" @@ -194,6 +195,8 @@ func newTemplateCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f := cmd.Flags() addInstallFlags(cmd, f, client, valueOpts) + f.Var(cli.NewQuantityBytesValue(&client.MaxChartSize), "max-chart-size", "maximum size for a decompressed chart (e.g., 500Ki, 5Mi)") + f.Var(cli.NewQuantityBytesValue(&client.MaxChartFileSize), "max-file-size", "maximum size for a single file in a chart (e.g., 5Mi, 10Mi)") f.StringArrayVarP(&showFiles, "show-only", "s", []string{}, "only show manifests rendered from the given templates") f.StringVar(&client.OutputDir, "output-dir", "", "writes the executed templates to files in output-dir instead of stdout") f.BoolVar(&validate, "validate", false, "deprecated") diff --git a/pkg/cmd/template_test.go b/pkg/cmd/template_test.go index 5bcccf5d0..1c00b74ac 100644 --- a/pkg/cmd/template_test.go +++ b/pkg/cmd/template_test.go @@ -166,6 +166,18 @@ func TestTemplateCmd(t *testing.T) { cmd: fmt.Sprintf("template '%s' -f %s/extra_values.yaml", chartPath, chartPath), golden: "output/template-subchart-cm-set-file.txt", }, + { + name: "template with restricted max size (with IEC size)", + cmd: "template too-big testdata/testcharts/oci-dependent-chart-0.1.0.tgz --max-chart-size=1Ki", + wantError: true, + golden: "output/template-with-restricted-chart-size.txt", + }, + { + name: "template with restricted max file size", + cmd: "template too-big testdata/testcharts/compressedchart-0.1.0.tgz --max-file-size=10", + wantError: true, + golden: "output/template-with-restricted-file-size.txt", + }, } runTestCmd(t, tests) } diff --git a/pkg/cmd/testdata/output/env-comp.txt b/pkg/cmd/testdata/output/env-comp.txt index 9d38ee464..6f560ef58 100644 --- a/pkg/cmd/testdata/output/env-comp.txt +++ b/pkg/cmd/testdata/output/env-comp.txt @@ -13,6 +13,8 @@ HELM_KUBECONTEXT HELM_KUBEINSECURE_SKIP_TLS_VERIFY HELM_KUBETLS_SERVER_NAME HELM_KUBETOKEN +HELM_MAX_CHART_SIZE +HELM_MAX_FILE_SIZE HELM_MAX_HISTORY HELM_NAMESPACE HELM_PLUGINS diff --git a/pkg/cmd/testdata/output/install-with-restricted-chart-size.txt b/pkg/cmd/testdata/output/install-with-restricted-chart-size.txt new file mode 100644 index 000000000..565547228 --- /dev/null +++ b/pkg/cmd/testdata/output/install-with-restricted-chart-size.txt @@ -0,0 +1 @@ +Error: INSTALLATION FAILED: unable to load chart archive: decompressed chart is larger than the maximum size 42 diff --git a/pkg/cmd/testdata/output/install-with-restricted-file-size.txt b/pkg/cmd/testdata/output/install-with-restricted-file-size.txt new file mode 100644 index 000000000..f5792e6a3 --- /dev/null +++ b/pkg/cmd/testdata/output/install-with-restricted-file-size.txt @@ -0,0 +1 @@ +Error: INSTALLATION FAILED: unable to load chart archive: decompressed chart file "bigchart/Chart.yaml" is larger than the maximum file size 1Ki diff --git a/pkg/cmd/testdata/output/template-with-restricted-chart-size.txt b/pkg/cmd/testdata/output/template-with-restricted-chart-size.txt new file mode 100644 index 000000000..94acd948d --- /dev/null +++ b/pkg/cmd/testdata/output/template-with-restricted-chart-size.txt @@ -0,0 +1 @@ +Error: unable to load chart archive: decompressed chart is larger than the maximum size 1Ki diff --git a/pkg/cmd/testdata/output/template-with-restricted-file-size.txt b/pkg/cmd/testdata/output/template-with-restricted-file-size.txt new file mode 100644 index 000000000..d08344f7f --- /dev/null +++ b/pkg/cmd/testdata/output/template-with-restricted-file-size.txt @@ -0,0 +1 @@ +Error: unable to load chart archive: decompressed chart file "compressedchart/Chart.yaml" is larger than the maximum file size 10 diff --git a/pkg/cmd/testdata/output/upgrade-failed-max-chart-size.txt b/pkg/cmd/testdata/output/upgrade-failed-max-chart-size.txt new file mode 100644 index 000000000..5bf16467a --- /dev/null +++ b/pkg/cmd/testdata/output/upgrade-failed-max-chart-size.txt @@ -0,0 +1 @@ +Error: unable to load chart archive: decompressed chart is larger than the maximum size 52 diff --git a/pkg/cmd/testdata/output/upgrade-with-restricted-chart-size-env.txt b/pkg/cmd/testdata/output/upgrade-with-restricted-chart-size-env.txt new file mode 100644 index 000000000..f11530e94 --- /dev/null +++ b/pkg/cmd/testdata/output/upgrade-with-restricted-chart-size-env.txt @@ -0,0 +1 @@ +Error: unable to load chart archive: decompressed chart is larger than the maximum size 10 diff --git a/pkg/cmd/testdata/output/upgrade-with-restricted-file-size-env.txt b/pkg/cmd/testdata/output/upgrade-with-restricted-file-size-env.txt new file mode 100644 index 000000000..f85050f65 --- /dev/null +++ b/pkg/cmd/testdata/output/upgrade-with-restricted-file-size-env.txt @@ -0,0 +1 @@ +Error: unable to load chart archive: decompressed chart file "bigchart/values.yaml" is larger than the maximum file size 2Ki diff --git a/pkg/cmd/testdata/testcharts/bigchart-0.1.0.tgz b/pkg/cmd/testdata/testcharts/bigchart-0.1.0.tgz new file mode 100644 index 000000000..f00a831e9 Binary files /dev/null and b/pkg/cmd/testdata/testcharts/bigchart-0.1.0.tgz differ diff --git a/pkg/cmd/testdata/testcharts/bigchart/Chart.yaml b/pkg/cmd/testdata/testcharts/bigchart/Chart.yaml new file mode 100644 index 000000000..cffbf2773 --- /dev/null +++ b/pkg/cmd/testdata/testcharts/bigchart/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: bigchart +description: A test chart for size limit testing +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/pkg/cmd/testdata/testcharts/bigchart/templates/_helpers.tpl b/pkg/cmd/testdata/testcharts/bigchart/templates/_helpers.tpl new file mode 100644 index 000000000..d06150caa --- /dev/null +++ b/pkg/cmd/testdata/testcharts/bigchart/templates/_helpers.tpl @@ -0,0 +1,14 @@ +{{/* +Simple helper for testing - returns the chart name +*/}} +{{- define "bigchart.name" -}} +{{- .Chart.Name }} +{{- end }} + +{{/* +Simple helper for testing - returns the release name +*/}} +{{- define "bigchart.fullname" -}} +{{- .Release.Name }} +{{- end }} + diff --git a/pkg/cmd/testdata/testcharts/bigchart/values.yaml b/pkg/cmd/testdata/testcharts/bigchart/values.yaml new file mode 100644 index 000000000..3349d7ead --- /dev/null +++ b/pkg/cmd/testdata/testcharts/bigchart/values.yaml @@ -0,0 +1,37 @@ +# Test values for bigchart - used for testing HELM_MAX_CHART_SIZE and HELM_MAX_FILE_SIZE + +replicaCount: 1 + +image: + repository: nginx + pullPolicy: IfNotPresent + tag: "1.21.0" + +service: + type: ClusterIP + port: 80 + +# Large data section to make this chart exceed 3KB when compressed +# This is used for testing size limit environment variables +testData: + # Each item below is approximately 132 characters + item001: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item002: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item003: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item004: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item005: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item006: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item007: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item008: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item009: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item010: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item011: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item012: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item013: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item014: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item015: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item016: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item017: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item018: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item019: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." + item020: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." \ No newline at end of file diff --git a/pkg/cmd/upgrade.go b/pkg/cmd/upgrade.go index 918d6f5b8..119f4a347 100644 --- a/pkg/cmd/upgrade.go +++ b/pkg/cmd/upgrade.go @@ -32,6 +32,8 @@ import ( "helm.sh/helm/v4/pkg/action" ci "helm.sh/helm/v4/pkg/chart" "helm.sh/helm/v4/pkg/chart/loader" + "helm.sh/helm/v4/pkg/chart/loader/archive" + "helm.sh/helm/v4/pkg/cli" "helm.sh/helm/v4/pkg/cli/output" "helm.sh/helm/v4/pkg/cli/values" "helm.sh/helm/v4/pkg/cmd/require" @@ -83,6 +85,9 @@ which can contain sensitive values. To hide Kubernetes Secrets use the func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { client := action.NewUpgrade(cfg) + // Initialize from environment settings so they serve as defaults for the flags + client.MaxChartSize = settings.MaxChartSize + client.MaxChartFileSize = settings.MaxChartFileSize valueOpts := &values.Options{} var outfmt output.Format var createNamespace bool @@ -192,8 +197,16 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } + opts := archive.DefaultOptions + if client.MaxChartSize > 0 { + opts.MaxDecompressedChartSize = client.MaxChartSize + } + if client.MaxChartFileSize > 0 { + opts.MaxDecompressedFileSize = client.MaxChartFileSize + } + // Check chart dependencies to make sure all are present in /charts - ch, err := loader.Load(chartPath) + ch, err := loader.LoadWithOptions(chartPath, opts) if err != nil { return err } @@ -221,7 +234,7 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { return err } // Reload the chart with the updated Chart.lock file. - if ch, err = loader.Load(chartPath); err != nil { + if ch, err = loader.LoadWithOptions(chartPath, opts); err != nil { return fmt.Errorf("failed reloading chart after repo update: %w", err) } } else { @@ -299,6 +312,8 @@ func newUpgradeCmd(cfg *action.Configuration, out io.Writer) *cobra.Command { f.BoolVar(&client.DependencyUpdate, "dependency-update", false, "update dependencies if they are missing before installing the chart") f.BoolVar(&client.EnableDNS, "enable-dns", false, "enable DNS lookups when rendering templates") f.BoolVar(&client.TakeOwnership, "take-ownership", false, "if set, upgrade will ignore the check for helm annotations and take ownership of the existing resources") + f.Var(cli.NewQuantityBytesValue(&client.MaxChartSize), "max-chart-size", "maximum size for a decompressed chart (e.g., 100Mi, 1Gi; default is 100Mi)") + f.Var(cli.NewQuantityBytesValue(&client.MaxChartFileSize), "max-file-size", "maximum size for a single file in a chart (e.g., 5Mi, 10Mi; default is 5Mi)") addDryRunFlag(cmd) addChartPathOptionsFlags(f, &client.ChartPathOptions) addValueOptionsFlags(f, valueOpts) diff --git a/pkg/cmd/upgrade_test.go b/pkg/cmd/upgrade_test.go index 0ae1e3561..7a49d1ae5 100644 --- a/pkg/cmd/upgrade_test.go +++ b/pkg/cmd/upgrade_test.go @@ -29,6 +29,7 @@ import ( chart "helm.sh/helm/v4/pkg/chart/v2" "helm.sh/helm/v4/pkg/chart/v2/loader" chartutil "helm.sh/helm/v4/pkg/chart/v2/util" + "helm.sh/helm/v4/pkg/cli" rcommon "helm.sh/helm/v4/pkg/release/common" release "helm.sh/helm/v4/pkg/release/v1" ) @@ -83,6 +84,7 @@ func TestUpgradeCmd(t *testing.T) { missingDepsPath := "testdata/testcharts/chart-missing-deps" badDepsPath := "testdata/testcharts/chart-bad-requirements" presentDepsPath := "testdata/testcharts/chart-with-subchart-update" + compressedPath := "testdata/testcharts/compressedchart-0.1.0.tgz" relWithStatusMock := func(n string, v int, ch *chart.Chart, status rcommon.Status) *release.Release { return release.Mock(&release.MockReleaseOptions{Name: n, Version: v, Chart: ch, Status: status}) @@ -190,10 +192,66 @@ func TestUpgradeCmd(t *testing.T) { golden: "output/upgrade-uninstalled-with-keep-history.txt", rels: []*release.Release{relWithStatusMock("funny-bunny", 2, ch, rcommon.StatusUninstalled)}, }, + { + name: "upgrade with restricted max size", + cmd: fmt.Sprintf("upgrade too-big '%s' --max-chart-size=52", compressedPath), + wantError: true, + golden: "output/upgrade-failed-max-chart-size.txt", + }, } runTestCmd(t, tests) } +func TestUpgradeWithEnvVars(t *testing.T) { + tests := []struct { + name string + cmd string + envVars map[string]string + wantError bool + golden string + }{ + { + name: "upgrade with HELM_MAX_CHART_SIZE env var with bytes", + cmd: "upgrade too-big testdata/testcharts/compressedchart-0.1.0.tgz", + envVars: map[string]string{ + "HELM_MAX_CHART_SIZE": "10", + }, + wantError: true, + golden: "output/upgrade-with-restricted-chart-size-env.txt", + }, + { + name: "upgrade with HELM_MAX_FILE_SIZE env var with Quantity suffix", + cmd: "upgrade test-max-file testdata/testcharts/bigchart-0.1.0.tgz", + envVars: map[string]string{ + "HELM_MAX_FILE_SIZE": "2Ki", + }, + wantError: true, + golden: "output/upgrade-with-restricted-file-size-env.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + defer resetEnv()() + + for k, v := range tt.envVars { + t.Setenv(k, v) + } + // Reset settings to pick up env vars + settings = cli.New() + + test := cmdTestCase{ + name: tt.name, + cmd: tt.cmd, + golden: tt.golden, + wantError: tt.wantError, + } + + runTestCmd(t, []cmdTestCase{test}) + }) + } +} + func TestUpgradeWithValue(t *testing.T) { releaseName := "funny-bunny-v2" relMock, ch, chartPath := prepareMockRelease(t, releaseName)