diff --git a/util/oci/client.go b/util/oci/client.go index 3906b032d2a02..9517c0cd44c2f 100644 --- a/util/oci/client.go +++ b/util/oci/client.go @@ -44,6 +44,11 @@ var ( indexLock = sync.NewKeyLock() ) +const ( + helmOCIConfigType = "application/vnd.cncf.helm.config.v1+json" + helmOCILayerType = "application/vnd.cncf.helm.chart.content.v1.tar+gzip" +) + var _ Client = &nativeOCIClient{} type tagsCache interface { @@ -260,6 +265,8 @@ func (c *nativeOCIClient) extract(ctx context.Context, digest string) (string, u return "", nil, err } + var isHelmChart bool + if !exists { ociManifest, err := getOCIManifest(ctx, digest, c.repo) if err != nil { @@ -272,16 +279,25 @@ func (c *nativeOCIClient) extract(ctx context.Context, digest string) (string, u return "", nil, fmt.Errorf("expected no more than 10 oci layers, got %d", len(ociManifest.Layers)) } + isHelmChart = ociManifest.Config.MediaType == helmOCIConfigType + contentLayers := 0 // Strictly speaking we only allow for a single content layer. There are images which contains extra layers, such // as provenance/attestation layers. Pending a better story to do this natively, we will skip such layers for now. for _, layer := range ociManifest.Layers { - if isContentLayer(layer.MediaType) { + // For Helm charts, only look for the specific Helm chart content layer + if isHelmChart { + if isHelmOCI(layer.MediaType) { + if !slices.Contains(c.allowedMediaTypes, layer.MediaType) { + return "", nil, fmt.Errorf("oci layer media type %s is not in the list of allowed media types", layer.MediaType) + } + contentLayers++ + } + } else if isContentLayer(layer.MediaType) { if !slices.Contains(c.allowedMediaTypes, layer.MediaType) { return "", nil, fmt.Errorf("oci layer media type %s is not in the list of allowed media types", layer.MediaType) } - contentLayers++ } } @@ -301,7 +317,15 @@ func (c *nativeOCIClient) extract(ctx context.Context, digest string) (string, u maxSize = math.MaxInt64 } - manifestsDir, err := extractContentToManifestsDir(ctx, cachedPath, digest, maxSize) + if !isHelmChart { + // Get the manifest to determine if it's a Helm chart for extraction + ociManifest, err := getOCIManifestFromCache(ctx, cachedPath, digest) + if err != nil { + return "", nil, fmt.Errorf("error getting oci manifest for extraction: %w", err) + } + isHelmChart = ociManifest.Config.MediaType == helmOCIConfigType + } + manifestsDir, err := extractContentToManifestsDir(ctx, cachedPath, digest, maxSize, isHelmChart) if err != nil { return manifestsDir, nil, fmt.Errorf("cannot extract contents of oci image with revision %s: %w", digest, err) } @@ -345,13 +369,7 @@ func (c *nativeOCIClient) digestMetadata(ctx context.Context, digest string) (*i if err != nil { return nil, fmt.Errorf("error fetching oci metadata path for digest %s: %w", digest, err) } - - repo, err := oci.NewFromTar(ctx, path) - if err != nil { - return nil, fmt.Errorf("error extracting oci image for digest %s: %w", digest, err) - } - - return getOCIManifest(ctx, digest, repo) + return getOCIManifestFromCache(ctx, path, digest) } func (c *nativeOCIClient) ResolveRevision(ctx context.Context, revision string, noCache bool) (string, error) { @@ -543,8 +561,8 @@ func saveCompressedImageToPath(ctx context.Context, digest string, repo oras.Rea } // extractContentToManifestsDir looks up a locally stored OCI image, and extracts the embedded compressed layer which contains -// K8s manifests to a temporary directory -func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string, maxSize int64) (string, error) { +// K8s manifests to a temp dir. +func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string, maxSize int64, isHelmChart bool) (string, error) { manifestsDir, err := files.CreateTempDir(os.TempDir()) if err != nil { return manifestsDir, err @@ -561,7 +579,7 @@ func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string } defer os.RemoveAll(tempDir) - fs, err := newCompressedLayerFileStore(manifestsDir, tempDir, maxSize) + fs, err := newCompressedLayerFileStore(manifestsDir, tempDir, maxSize, isHelmChart) if err != nil { return manifestsDir, err } @@ -574,26 +592,32 @@ func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string type compressedLayerExtracterStore struct { *file.Store - dest string - maxSize int64 + dest string + maxSize int64 + isHelmChart bool } -func newCompressedLayerFileStore(dest, tempDir string, maxSize int64) (*compressedLayerExtracterStore, error) { +func newCompressedLayerFileStore(dest, tempDir string, maxSize int64, isHelmChart bool) (*compressedLayerExtracterStore, error) { f, err := file.New(tempDir) if err != nil { return nil, err } - return &compressedLayerExtracterStore{f, dest, maxSize}, nil + return &compressedLayerExtracterStore{f, dest, maxSize, isHelmChart}, nil } func isHelmOCI(mediaType string) bool { - return mediaType == "application/vnd.cncf.helm.chart.content.v1.tar+gzip" + return mediaType == helmOCILayerType } // Push looks in all the layers of an OCI image. Once it finds a layer that is compressed, it extracts the layer to a tempDir // and then renames the temp dir to the directory where the repo-server expects to find k8s manifests. func (s *compressedLayerExtracterStore) Push(ctx context.Context, desc imagev1.Descriptor, content io.Reader) error { + // For Helm charts, only extract the Helm chart content layer, skip all other layers + if s.isHelmChart && !isHelmOCI(desc.MediaType) { + return s.Store.Push(ctx, desc, content) + } + if isContentLayer(desc.MediaType) { srcDir, err := files.CreateTempDir(os.TempDir()) if err != nil { @@ -682,6 +706,15 @@ func getOCIManifest(ctx context.Context, digest string, repo oras.ReadOnlyTarget return &manifest, nil } +// getOCIManifestFromCache retrieves an OCI manifest from a cached tar file +func getOCIManifestFromCache(ctx context.Context, cachedPath, digest string) (*imagev1.Manifest, error) { + repo, err := oci.NewFromTar(ctx, cachedPath) + if err != nil { + return nil, fmt.Errorf("error creating oci store from cache: %w", err) + } + return getOCIManifest(ctx, digest, repo) +} + // WithEventHandlers sets the git client event handlers func WithEventHandlers(handlers EventHandlers) ClientOpts { return func(c *nativeOCIClient) { diff --git a/util/oci/client_test.go b/util/oci/client_test.go index ec4bd8f8e9aa2..db5f5c95b96f0 100644 --- a/util/oci/client_test.go +++ b/util/oci/client_test.go @@ -30,9 +30,14 @@ type layerConf struct { } func generateManifest(t *testing.T, store *memory.Store, layerDescs ...layerConf) string { + t.Helper() + return generateManifestWithConfig(t, store, imagev1.MediaTypeImageConfig, layerDescs...) +} + +func generateManifestWithConfig(t *testing.T, store *memory.Store, configMediaType string, layerDescs ...layerConf) string { t.Helper() configBlob := []byte("Hello config") - configDesc := content.NewDescriptorFromBytes(imagev1.MediaTypeImageConfig, configBlob) + configDesc := content.NewDescriptorFromBytes(configMediaType, configBlob) var layers []imagev1.Descriptor @@ -281,6 +286,278 @@ func Test_nativeOCIClient_Extract(t *testing.T) { }, }, }, + { + name: "helm chart with multiple layers (provenance + chart content) should succeed", + fields: fields{ + allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + }, + args: args{ + digestFunc: func(store *memory.Store) string { + chartDir := t.TempDir() + chartName := "mychart" + + parent := filepath.Join(chartDir, "parent") + require.NoError(t, os.Mkdir(parent, 0o755)) + + chartPath := filepath.Join(parent, chartName) + require.NoError(t, os.Mkdir(chartPath, 0o755)) + + addFileToDirectory(t, chartPath, "Chart.yaml", "helm chart content") + + temp, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + defer temp.Close() + _, err = files.Tgz(parent, nil, nil, temp) + require.NoError(t, err) + _, err = temp.Seek(0, io.SeekStart) + require.NoError(t, err) + chartBlob, err := io.ReadAll(temp) + require.NoError(t, err) + + // Create provenance layer + provenanceBlob := []byte("provenance data") + + return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json", + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob}, + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob}) + }, + postValidationFunc: func(_, path string, _ Client, _ fields, _ args) { + // Verify only chart content was extracted, not provenance + chartDir, err := os.ReadDir(path) + require.NoError(t, err) + require.Len(t, chartDir, 1) + require.Equal(t, "Chart.yaml", chartDir[0].Name()) + + chartYaml, err := os.Open(filepath.Join(path, chartDir[0].Name())) + require.NoError(t, err) + contents, err := io.ReadAll(chartYaml) + require.NoError(t, err) + require.Equal(t, "helm chart content", string(contents)) + }, + manifestMaxExtractedSize: 10000, + disableManifestMaxExtractedSize: false, + }, + }, + { + name: "helm chart with multiple layers (attestation + provenance + chart content) should succeed", + fields: fields{ + allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + }, + args: args{ + digestFunc: func(store *memory.Store) string { + chartDir := t.TempDir() + chartName := "mychart" + + parent := filepath.Join(chartDir, "parent") + require.NoError(t, os.Mkdir(parent, 0o755)) + + chartPath := filepath.Join(parent, chartName) + require.NoError(t, os.Mkdir(chartPath, 0o755)) + + addFileToDirectory(t, chartPath, "Chart.yaml", "multi-layer chart") + addFileToDirectory(t, chartPath, "values.yaml", "key: value") + + temp, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + defer temp.Close() + _, err = files.Tgz(parent, nil, nil, temp) + require.NoError(t, err) + _, err = temp.Seek(0, io.SeekStart) + require.NoError(t, err) + chartBlob, err := io.ReadAll(temp) + require.NoError(t, err) + + // Create multiple non-content layers + attestationBlob := []byte("attestation data") + provenanceBlob := []byte("provenance data") + + return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json", + layerConf{content.NewDescriptorFromBytes("application/vnd.in-toto+json", attestationBlob), attestationBlob}, + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob}, + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob}) + }, + postValidationFunc: func(_, path string, _ Client, _ fields, _ args) { + // Verify only chart content was extracted + chartDir, err := os.ReadDir(path) + require.NoError(t, err) + require.Len(t, chartDir, 2) // Chart.yaml and values.yaml + + files := make(map[string]bool) + for _, f := range chartDir { + files[f.Name()] = true + } + require.True(t, files["Chart.yaml"]) + require.True(t, files["values.yaml"]) + + // Ensure no provenance or attestation files were extracted + require.False(t, files["provenance"]) + require.False(t, files["attestation"]) + }, + manifestMaxExtractedSize: 10000, + disableManifestMaxExtractedSize: false, + }, + }, + { + name: "helm chart with only provenance layer should fail (no chart content)", + fields: fields{ + allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip"}, + }, + args: args{ + digestFunc: func(store *memory.Store) string { + provenanceBlob := []byte("provenance data") + return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json", + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob}) + }, + manifestMaxExtractedSize: 1000, + disableManifestMaxExtractedSize: false, + }, + expectedError: errors.New("expected only a single oci content layer, got 0"), + }, + { + name: "non-helm OCI with multiple content layers should still fail", + fields: fields{ + allowedMediaTypes: []string{imagev1.MediaTypeImageLayerGzip}, + }, + args: args{ + digestFunc: func(store *memory.Store) string { + layerBlob1 := createGzippedTarWithContent(t, "file1.yaml", "content1") + layerBlob2 := createGzippedTarWithContent(t, "file2.yaml", "content2") + // Using standard image config, not Helm config + return generateManifest(t, store, + layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob1), layerBlob1}, + layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, layerBlob2), layerBlob2}) + }, + manifestMaxExtractedSize: 1000, + disableManifestMaxExtractedSize: false, + }, + expectedError: errors.New("expected only a single oci content layer, got 2"), + }, + { + name: "helm chart with extra content layer should succeed and ignore extra layer", + fields: fields{ + allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip", imagev1.MediaTypeImageLayerGzip}, + }, + args: args{ + digestFunc: func(store *memory.Store) string { + chartDir := t.TempDir() + chartName := "mychart" + + parent := filepath.Join(chartDir, "parent") + require.NoError(t, os.Mkdir(parent, 0o755)) + + chartPath := filepath.Join(parent, chartName) + require.NoError(t, os.Mkdir(chartPath, 0o755)) + + addFileToDirectory(t, chartPath, "Chart.yaml", "chart with extra docker layer") + + temp, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + defer temp.Close() + _, err = files.Tgz(parent, nil, nil, temp) + require.NoError(t, err) + _, err = temp.Seek(0, io.SeekStart) + require.NoError(t, err) + chartBlob, err := io.ReadAll(temp) + require.NoError(t, err) + + // Extra OCI layer that Docker/some registries add + extraLayerBlob := createGzippedTarWithContent(t, "extra.txt", "extra layer content") + + // Helm chart with proper Helm content layer + extra OCI layer that should be ignored + return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json", + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob}, + layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, extraLayerBlob), extraLayerBlob}) + }, + postValidationFunc: func(_, path string, _ Client, _ fields, _ args) { + // Verify only Helm chart content was extracted, not the extra OCI layer + chartDir, err := os.ReadDir(path) + require.NoError(t, err) + require.Len(t, chartDir, 1) + require.Equal(t, "Chart.yaml", chartDir[0].Name()) + + chartYaml, err := os.Open(filepath.Join(path, chartDir[0].Name())) + require.NoError(t, err) + contents, err := io.ReadAll(chartYaml) + require.NoError(t, err) + require.Equal(t, "chart with extra docker layer", string(contents)) + }, + manifestMaxExtractedSize: 10000, + disableManifestMaxExtractedSize: false, + }, + }, + { + name: "helm chart with extra OCI layer + provenance should extract only helm chart content", + fields: fields{ + allowedMediaTypes: []string{"application/vnd.cncf.helm.chart.content.v1.tar+gzip", imagev1.MediaTypeImageLayerGzip}, + }, + args: args{ + digestFunc: func(store *memory.Store) string { + chartDir := t.TempDir() + chartName := "mychart" + + parent := filepath.Join(chartDir, "parent") + require.NoError(t, os.Mkdir(parent, 0o755)) + + chartPath := filepath.Join(parent, chartName) + require.NoError(t, os.Mkdir(chartPath, 0o755)) + + templatesPath := filepath.Join(chartPath, "templates") + require.NoError(t, os.Mkdir(templatesPath, 0o755)) + + addFileToDirectory(t, chartPath, "Chart.yaml", "multi-layer helm chart") + addFileToDirectory(t, templatesPath, "deployment.yaml", "apiVersion: apps/v1") + + temp, err := os.CreateTemp(t.TempDir(), "") + require.NoError(t, err) + defer temp.Close() + _, err = files.Tgz(parent, nil, nil, temp) + require.NoError(t, err) + _, err = temp.Seek(0, io.SeekStart) + require.NoError(t, err) + chartBlob, err := io.ReadAll(temp) + require.NoError(t, err) + + provenanceBlob := []byte("provenance data") + extraLayerBlob := createGzippedTarWithContent(t, "extra.txt", "extra oci layer") + + // Helm chart with: Helm content layer + extra OCI layer + provenance + // Only the Helm content layer should be extracted + return generateManifestWithConfig(t, store, "application/vnd.cncf.helm.config.v1+json", + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.content.v1.tar+gzip", chartBlob), chartBlob}, + layerConf{content.NewDescriptorFromBytes(imagev1.MediaTypeImageLayerGzip, extraLayerBlob), extraLayerBlob}, + layerConf{content.NewDescriptorFromBytes("application/vnd.cncf.helm.chart.provenance.v1.prov", provenanceBlob), provenanceBlob}) + }, + postValidationFunc: func(_, path string, _ Client, _ fields, _ args) { + // Verify only Helm chart content was extracted + entries, err := os.ReadDir(path) + require.NoError(t, err) + require.Len(t, entries, 2) // Chart.yaml and templates dir + + files := make(map[string]bool) + for _, e := range entries { + files[e.Name()] = true + } + require.True(t, files["Chart.yaml"]) + require.True(t, files["templates"]) + + // Verify Chart.yaml content + chartYaml, err := os.ReadFile(filepath.Join(path, "Chart.yaml")) + require.NoError(t, err) + require.YAMLEq(t, "multi-layer helm chart", string(chartYaml)) + + // Verify templates/deployment.yaml exists + deploymentYaml, err := os.ReadFile(filepath.Join(path, "templates", "deployment.yaml")) + require.NoError(t, err) + require.YAMLEq(t, "apiVersion: apps/v1", string(deploymentYaml)) + + // Ensure extra OCI layer and provenance were not extracted + require.False(t, files["extra.txt"]) + require.False(t, files["provenance"]) + }, + manifestMaxExtractedSize: 10000, + disableManifestMaxExtractedSize: false, + }, + }, } for _, tt := range tests {