@ -19,6 +19,7 @@ import (
"bytes"
"bytes"
"crypto/sha256"
"crypto/sha256"
"encoding/hex"
"encoding/hex"
"encoding/json"
"errors"
"errors"
"fmt"
"fmt"
"io"
"io"
@ -29,6 +30,8 @@ import (
"path/filepath"
"path/filepath"
"strings"
"strings"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"helm.sh/helm/v4/internal/fileutil"
"helm.sh/helm/v4/internal/fileutil"
ifs "helm.sh/helm/v4/internal/third_party/dep/fs"
ifs "helm.sh/helm/v4/internal/third_party/dep/fs"
"helm.sh/helm/v4/internal/urlutil"
"helm.sh/helm/v4/internal/urlutil"
@ -106,7 +109,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
c . Cache = & DiskCache { Root : c . ContentCache }
c . Cache = & DiskCache { Root : c . ContentCache }
slog . Debug ( "set up default downloader cache" )
slog . Debug ( "set up default downloader cache" )
}
}
hash , u , err := c . ResolveChartVersion ( ref , version )
hash , u , err := c . resolveCacheDigest ( ref , version )
if err != nil {
if err != nil {
return "" , nil , err
return "" , nil , err
}
}
@ -138,7 +141,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
if pth , err := c . Cache . Get ( digest32 , CacheChart ) ; err == nil {
if pth , err := c . Cache . Get ( digest32 , CacheChart ) ; err == nil {
fdata , err := os . ReadFile ( pth )
fdata , err := os . ReadFile ( pth )
if err == nil {
if err == nil {
if verr := verifyIndexDigest ( ref , u, hash, digest32 , fdata ) ; verr != nil {
if verr := verifyIndexDigest ( ref , hash, digest32 , fdata ) ; verr != nil {
// An entry that does not hash to the digest it is filed
// An entry that does not hash to the digest it is filed
// under cannot be trusted, whoever wrote it. Drop it and
// under cannot be trusted, whoever wrote it. Drop it and
// download the chart again rather than serving it.
// download the chart again rather than serving it.
@ -160,7 +163,7 @@ func (c *ChartDownloader) DownloadTo(ref, version, dest string) (string, *proven
if err != nil {
if err != nil {
return "" , nil , err
return "" , nil , err
}
}
if err := verifyIndexDigest ( ref , u, hash, digest32 , data . Bytes ( ) ) ; err != nil {
if err := verifyIndexDigest ( ref , hash, digest32 , data . Bytes ( ) ) ; err != nil {
return "" , nil , err
return "" , nil , err
}
}
}
}
@ -234,7 +237,7 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena
slog . Debug ( "set up default downloader cache" )
slog . Debug ( "set up default downloader cache" )
}
}
digestString , u , err := c . ResolveChartVersion ( ref , version )
digestString , u , err := c . resolveCacheDigest ( ref , version )
if err != nil {
if err != nil {
return "" , nil , err
return "" , nil , err
}
}
@ -268,7 +271,7 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena
// The cache is content addressed, but nothing has been enforcing
// The cache is content addressed, but nothing has been enforcing
// that, so an entry written by an older version of Helm may not
// that, so an entry written by an older version of Helm may not
// hash to the name it is filed under. Check before trusting it.
// hash to the name it is filed under. Check before trusting it.
if verr := verifyCachedChart ( ref , u, digestString, digest32 , cachePath ) ; verr != nil {
if verr := verifyCachedChart ( ref , digestString, digest32 , cachePath ) ; verr != nil {
slog . Debug ( "discarding cache entry that does not match its digest" , "id" , digestString )
slog . Debug ( "discarding cache entry that does not match its digest" , "id" , digestString )
_ = os . Remove ( cachePath )
_ = os . Remove ( cachePath )
} else {
} else {
@ -291,7 +294,7 @@ func (c *ChartDownloader) DownloadToCache(ref, version string) (string, *provena
// Check the bytes against the digest the index published for them
// Check the bytes against the digest the index published for them
// before they are written into the content cache under that digest.
// before they are written into the content cache under that digest.
if verr := verifyIndexDigest ( ref , u, digestString, digest32 , data . Bytes ( ) ) ; verr != nil {
if verr := verifyIndexDigest ( ref , digestString, digest32 , data . Bytes ( ) ) ; verr != nil {
return "" , nil , verr
return "" , nil , verr
}
}
@ -620,6 +623,60 @@ func loadRepoConfig(file string) (*repo.File, error) {
return r , nil
return r , nil
}
}
// resolveCacheDigest resolves ref like ResolveChartVersion, but returns the
// digest the content cache should use for the chart archive.
//
// For a repository chart that is the index digest, which is already the sha256
// of the archive. For an OCI reference pinned to a digest it is not: that digest
// names the manifest, so an archive cached under it could never be checked
// against its key. In that case the manifest is fetched (the registry client
// verifies it against the pinned digest) and the digest of its chart layer is
// returned instead, which keeps every cache entry keyed by its own content.
func ( c * ChartDownloader ) resolveCacheDigest ( ref , version string ) ( string , * url . URL , error ) {
d , u , err := c . ResolveChartVersion ( ref , version )
if err != nil || d == "" || u . Scheme != registry . OCIScheme {
return d , u , err
}
layer , err := c . ociChartLayerDigest ( u )
if err != nil {
return "" , nil , fmt . Errorf ( "unable to resolve chart layer for %s: %w" , ref , err )
}
return layer , u , nil
}
// ociChartLayerDigest fetches only the manifest u points at and returns the
// sha256 digest of its chart layer.
func ( c * ChartDownloader ) ociChartLayerDigest ( u * url . URL ) ( string , error ) {
generic := c . RegistryClient . Generic ( )
result , err := generic . PullGeneric ( strings . TrimPrefix ( u . String ( ) , registry . OCIScheme + "://" ) , registry . GenericPullOptions {
AllowedMediaTypes : [ ] string { ocispec . MediaTypeImageManifest } ,
} )
if err != nil {
return "" , err
}
data , err := generic . GetDescriptorData ( result . MemoryStore , result . Manifest )
if err != nil {
return "" , err
}
var manifest ocispec . Manifest
if err := json . Unmarshal ( data , & manifest ) ; err != nil {
return "" , err
}
for _ , layer := range manifest . Layers {
if layer . MediaType != registry . ChartLayerMediaType && layer . MediaType != registry . LegacyChartLayerMediaType {
continue
}
if layer . Digest . Algorithm ( ) != "sha256" {
return "" , fmt . Errorf ( "unsupported chart layer digest algorithm %q" , layer . Digest . Algorithm ( ) )
}
if err := layer . Digest . Validate ( ) ; err != nil {
return "" , err
}
return layer . Digest . String ( ) , nil
}
return "" , fmt . Errorf ( "manifest does not contain a layer with mediatype %s" , registry . ChartLayerMediaType )
}
// verifyIndexDigest checks chart archive bytes against the sha256 digest the
// verifyIndexDigest checks chart archive bytes against the sha256 digest the
// repository index publishes for them.
// repository index publishes for them.
//
//
@ -630,11 +687,10 @@ func loadRepoConfig(file string) (*repo.File, error) {
// own index was accepted without complaint.
// own index was accepted without complaint.
//
//
// It is a no-op when the index carries no digest, which is the case for a chart
// It is a no-op when the index carries no digest, which is the case for a chart
// referenced by a bare URL, so those keep working as before. OCI references are
// referenced by a bare URL, so those keep working as before. For an OCI
// excluded on purpose: the digest resolved for them identifies a manifest
// reference the digest is the chart layer digest from resolveCacheDigest.
// rather than the archive bytes, and the registry client already checks it.
func verifyIndexDigest ( ref , digestString string , want [ sha256 . Size ] byte , data [ ] byte ) error {
func verifyIndexDigest ( ref string , u * url . URL , digestString string , want [ sha256 . Size ] byte , data [ ] byte ) error {
if digestString == "" {
if ! indexDigestApplies ( u , digestString ) {
return nil
return nil
}
}
return compareChartDigest ( ref , sha256 . Sum256 ( data ) , want )
return compareChartDigest ( ref , sha256 . Sum256 ( data ) , want )
@ -643,8 +699,8 @@ func verifyIndexDigest(ref string, u *url.URL, digestString string, want [sha256
// verifyCachedChart checks an archive already in the content cache against the
// verifyCachedChart checks an archive already in the content cache against the
// digest it is filed under, hashing it as a stream so a large chart is not held
// digest it is filed under, hashing it as a stream so a large chart is not held
// in memory twice.
// in memory twice.
func verifyCachedChart ( ref string , u * url . URL , digestString string , want [ sha256 . Size ] byte , path string ) error {
func verifyCachedChart ( ref , digestString string , want [ sha256 . Size ] byte , path string ) error {
if ! indexDigestApplies ( u , digestString ) {
if digestString == "" {
return nil
return nil
}
}
f , err := os . Open ( path )
f , err := os . Open ( path )
@ -661,10 +717,6 @@ func verifyCachedChart(ref string, u *url.URL, digestString string, want [sha256
return compareChartDigest ( ref , got , want )
return compareChartDigest ( ref , got , want )
}
}
func indexDigestApplies ( u * url . URL , digestString string ) bool {
return digestString != "" && ( u == nil || u . Scheme != registry . OCIScheme )
}
func compareChartDigest ( ref string , got , want [ sha256 . Size ] byte ) error {
func compareChartDigest ( ref string , got , want [ sha256 . Size ] byte ) error {
if got != want {
if got != want {
return fmt . Errorf ( "chart %q does not match the digest recorded for it in the repository index: expected sha256:%s, got sha256:%s" ,
return fmt . Errorf ( "chart %q does not match the digest recorded for it in the repository index: expected sha256:%s, got sha256:%s" ,