github.com/argoproj/argo-cd/v3@v3.2.1/util/oci/client.go (about)

     1  package oci
     2  
     3  import (
     4  	"context"
     5  	"crypto/tls"
     6  	"crypto/x509"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"io"
    11  	"io/fs"
    12  	"math"
    13  	"net/http"
    14  	"net/url"
    15  	"os"
    16  	"path"
    17  	"path/filepath"
    18  	"slices"
    19  	"strings"
    20  	"time"
    21  
    22  	securejoin "github.com/cyphar/filepath-securejoin"
    23  	imagev1 "github.com/opencontainers/image-spec/specs-go/v1"
    24  	"oras.land/oras-go/v2/content/oci"
    25  
    26  	"github.com/argoproj/argo-cd/v3/util/versions"
    27  
    28  	"github.com/argoproj/pkg/sync"
    29  	log "github.com/sirupsen/logrus"
    30  
    31  	"github.com/argoproj/argo-cd/v3/util/cache"
    32  	utilio "github.com/argoproj/argo-cd/v3/util/io"
    33  	"github.com/argoproj/argo-cd/v3/util/io/files"
    34  	"github.com/argoproj/argo-cd/v3/util/proxy"
    35  
    36  	"oras.land/oras-go/v2"
    37  	"oras.land/oras-go/v2/content/file"
    38  	"oras.land/oras-go/v2/registry/remote"
    39  	"oras.land/oras-go/v2/registry/remote/auth"
    40  )
    41  
    42  var (
    43  	globalLock = sync.NewKeyLock()
    44  	indexLock  = sync.NewKeyLock()
    45  )
    46  
    47  var _ Client = &nativeOCIClient{}
    48  
    49  type tagsCache interface {
    50  	SetOCITags(repo string, indexData []byte) error
    51  	GetOCITags(repo string, indexData *[]byte) error
    52  }
    53  
    54  // Client is a generic OCI client interface that provides methods for interacting with an OCI (Open Container Initiative) registry.
    55  type Client interface {
    56  	// ResolveRevision resolves a tag, digest, or semantic version constraint to a concrete digest.
    57  	// If noCache is true, the resolution bypasses the local tags cache and queries the remote registry.
    58  	// If the revision is already a digest, it is returned as-is.
    59  	ResolveRevision(ctx context.Context, revision string, noCache bool) (string, error)
    60  
    61  	// DigestMetadata retrieves an OCI manifest for a given digest.
    62  	DigestMetadata(ctx context.Context, digest string) (*imagev1.Manifest, error)
    63  
    64  	// CleanCache is invoked on a hard-refresh or when the manifest cache has expired. This removes the OCI image from
    65  	// the cached path, which is looked up by the specified revision.
    66  	CleanCache(revision string) error
    67  
    68  	// Extract retrieves and unpacks the contents of an OCI image identified by the specified revision.
    69  	// If successful, the extracted contents are extracted to a randomized tempdir.
    70  	Extract(ctx context.Context, revision string) (string, utilio.Closer, error)
    71  
    72  	// TestRepo verifies the connectivity and accessibility of the repository.
    73  	TestRepo(ctx context.Context) (bool, error)
    74  
    75  	// GetTags retrieves the list of tags for the repository.
    76  	GetTags(ctx context.Context, noCache bool) ([]string, error)
    77  }
    78  
    79  type Creds struct {
    80  	Username           string
    81  	Password           string
    82  	CAPath             string
    83  	CertData           []byte
    84  	KeyData            []byte
    85  	InsecureSkipVerify bool
    86  	InsecureHTTPOnly   bool
    87  }
    88  
    89  type ClientOpts func(c *nativeOCIClient)
    90  
    91  func WithIndexCache(indexCache tagsCache) ClientOpts {
    92  	return func(c *nativeOCIClient) {
    93  		c.tagsCache = indexCache
    94  	}
    95  }
    96  
    97  func WithImagePaths(repoCachePaths utilio.TempPaths) ClientOpts {
    98  	return func(c *nativeOCIClient) {
    99  		c.repoCachePaths = repoCachePaths
   100  	}
   101  }
   102  
   103  func WithManifestMaxExtractedSize(manifestMaxExtractedSize int64) ClientOpts {
   104  	return func(c *nativeOCIClient) {
   105  		c.manifestMaxExtractedSize = manifestMaxExtractedSize
   106  	}
   107  }
   108  
   109  func WithDisableManifestMaxExtractedSize(disableManifestMaxExtractedSize bool) ClientOpts {
   110  	return func(c *nativeOCIClient) {
   111  		c.disableManifestMaxExtractedSize = disableManifestMaxExtractedSize
   112  	}
   113  }
   114  
   115  func NewClient(repoURL string, creds Creds, proxy, noProxy string, layerMediaTypes []string, opts ...ClientOpts) (Client, error) {
   116  	return NewClientWithLock(repoURL, creds, globalLock, proxy, noProxy, layerMediaTypes, opts...)
   117  }
   118  
   119  func NewClientWithLock(repoURL string, creds Creds, repoLock sync.KeyLock, proxyURL, noProxy string, layerMediaTypes []string, opts ...ClientOpts) (Client, error) {
   120  	ociRepo := strings.TrimPrefix(repoURL, "oci://")
   121  	repo, err := remote.NewRepository(ociRepo)
   122  	if err != nil {
   123  		return nil, fmt.Errorf("failed to initialize repository: %w", err)
   124  	}
   125  
   126  	repo.PlainHTTP = creds.InsecureHTTPOnly
   127  
   128  	var tlsConf *tls.Config
   129  	if !repo.PlainHTTP {
   130  		tlsConf, err = newTLSConfig(creds)
   131  		if err != nil {
   132  			return nil, fmt.Errorf("failed setup tlsConfig: %w", err)
   133  		}
   134  	}
   135  
   136  	client := &http.Client{
   137  		Transport: &http.Transport{
   138  			Proxy:             proxy.GetCallback(proxyURL, noProxy),
   139  			TLSClientConfig:   tlsConf,
   140  			DisableKeepAlives: true,
   141  		},
   142  		/*
   143  			CheckRedirect: func(req *http.Request, via []*http.Request) error {
   144  				return errors.New("redirects are not allowed")
   145  			},
   146  		*/
   147  	}
   148  	repo.Client = &auth.Client{
   149  		Client: client,
   150  		Cache:  nil,
   151  		Credential: auth.StaticCredential(repo.Reference.Registry, auth.Credential{
   152  			Username: creds.Username,
   153  			Password: creds.Password,
   154  		}),
   155  	}
   156  
   157  	parsed, err := url.Parse(repoURL)
   158  	if err != nil {
   159  		return nil, fmt.Errorf("failed to parse oci repo url: %w", err)
   160  	}
   161  
   162  	reg, err := remote.NewRegistry(parsed.Host)
   163  	if err != nil {
   164  		return nil, fmt.Errorf("failed to setup registry config: %w", err)
   165  	}
   166  	reg.PlainHTTP = repo.PlainHTTP
   167  	reg.Client = repo.Client
   168  	return newClientWithLock(ociRepo, repoLock, repo, func(ctx context.Context, last string) ([]string, error) {
   169  		var t []string
   170  
   171  		err := repo.Tags(ctx, last, func(tags []string) error {
   172  			t = append(t, tags...)
   173  			return nil
   174  		})
   175  
   176  		return t, err
   177  	}, reg.Ping, layerMediaTypes, opts...), nil
   178  }
   179  
   180  func newClientWithLock(repoURL string, repoLock sync.KeyLock, repo oras.ReadOnlyTarget, tagsFunc func(context.Context, string) ([]string, error), pingFunc func(ctx context.Context) error, layerMediaTypes []string, opts ...ClientOpts) Client {
   181  	c := &nativeOCIClient{
   182  		repoURL:           repoURL,
   183  		repoLock:          repoLock,
   184  		repo:              repo,
   185  		tagsFunc:          tagsFunc,
   186  		pingFunc:          pingFunc,
   187  		allowedMediaTypes: layerMediaTypes,
   188  	}
   189  	for i := range opts {
   190  		opts[i](c)
   191  	}
   192  	return c
   193  }
   194  
   195  // nativeOCIClient implements Client interface using oras-go
   196  type nativeOCIClient struct {
   197  	repoURL                         string
   198  	repo                            oras.ReadOnlyTarget
   199  	tagsFunc                        func(context.Context, string) ([]string, error)
   200  	repoLock                        sync.KeyLock
   201  	tagsCache                       tagsCache
   202  	repoCachePaths                  utilio.TempPaths
   203  	allowedMediaTypes               []string
   204  	manifestMaxExtractedSize        int64
   205  	disableManifestMaxExtractedSize bool
   206  	pingFunc                        func(ctx context.Context) error
   207  }
   208  
   209  // TestRepo verifies that the remote OCI repo can be connected to.
   210  func (c *nativeOCIClient) TestRepo(ctx context.Context) (bool, error) {
   211  	err := c.pingFunc(ctx)
   212  	return err == nil, err
   213  }
   214  
   215  func (c *nativeOCIClient) Extract(ctx context.Context, digest string) (string, utilio.Closer, error) {
   216  	cachedPath, err := c.getCachedPath(digest)
   217  	if err != nil {
   218  		return "", nil, fmt.Errorf("error getting oci path for digest %s: %w", digest, err)
   219  	}
   220  
   221  	c.repoLock.Lock(cachedPath)
   222  	defer c.repoLock.Unlock(cachedPath)
   223  
   224  	exists, err := fileExists(cachedPath)
   225  	if err != nil {
   226  		return "", nil, err
   227  	}
   228  
   229  	if !exists {
   230  		ociManifest, err := getOCIManifest(ctx, digest, c.repo)
   231  		if err != nil {
   232  			return "", nil, err
   233  		}
   234  
   235  		// Add a guard to defend against a ridiculous amount of layers. No idea what a good amount is, but normally we
   236  		// shouldn't expect more than 2-3 in most real world use cases.
   237  		if len(ociManifest.Layers) > 10 {
   238  			return "", nil, fmt.Errorf("expected no more than 10 oci layers, got %d", len(ociManifest.Layers))
   239  		}
   240  
   241  		contentLayers := 0
   242  
   243  		// Strictly speaking we only allow for a single content layer. There are images which contains extra layers, such
   244  		// as provenance/attestation layers. Pending a better story to do this natively, we will skip such layers for now.
   245  		for _, layer := range ociManifest.Layers {
   246  			if isContentLayer(layer.MediaType) {
   247  				if !slices.Contains(c.allowedMediaTypes, layer.MediaType) {
   248  					return "", nil, fmt.Errorf("oci layer media type %s is not in the list of allowed media types", layer.MediaType)
   249  				}
   250  
   251  				contentLayers++
   252  			}
   253  		}
   254  
   255  		if contentLayers != 1 {
   256  			return "", nil, fmt.Errorf("expected only a single oci content layer, got %d", contentLayers)
   257  		}
   258  
   259  		err = saveCompressedImageToPath(ctx, digest, c.repo, cachedPath)
   260  		if err != nil {
   261  			return "", nil, fmt.Errorf("could not save oci digest %s: %w", digest, err)
   262  		}
   263  	}
   264  
   265  	maxSize := c.manifestMaxExtractedSize
   266  	if c.disableManifestMaxExtractedSize {
   267  		maxSize = math.MaxInt64
   268  	}
   269  
   270  	manifestsDir, err := extractContentToManifestsDir(ctx, cachedPath, digest, maxSize)
   271  	if err != nil {
   272  		return manifestsDir, nil, fmt.Errorf("cannot extract contents of oci image with revision %s: %w", digest, err)
   273  	}
   274  
   275  	return manifestsDir, utilio.NewCloser(func() error {
   276  		return os.RemoveAll(manifestsDir)
   277  	}), nil
   278  }
   279  
   280  func (c *nativeOCIClient) getCachedPath(version string) (string, error) {
   281  	keyData, err := json.Marshal(map[string]string{"url": c.repoURL, "version": version})
   282  	if err != nil {
   283  		return "", err
   284  	}
   285  	return c.repoCachePaths.GetPath(string(keyData))
   286  }
   287  
   288  func (c *nativeOCIClient) CleanCache(revision string) error {
   289  	cachePath, err := c.getCachedPath(revision)
   290  	if err != nil {
   291  		return fmt.Errorf("error cleaning oci path for revision %s: %w", revision, err)
   292  	}
   293  	return os.RemoveAll(cachePath)
   294  }
   295  
   296  // DigestMetadata extracts the OCI manifest for a given revision and returns it to the caller.
   297  func (c *nativeOCIClient) DigestMetadata(ctx context.Context, digest string) (*imagev1.Manifest, error) {
   298  	path, err := c.getCachedPath(digest)
   299  	if err != nil {
   300  		return nil, fmt.Errorf("error fetching oci metadata path for digest %s: %w", digest, err)
   301  	}
   302  
   303  	repo, err := oci.NewFromTar(ctx, path)
   304  	if err != nil {
   305  		return nil, fmt.Errorf("error extracting oci image for digest %s: %w", digest, err)
   306  	}
   307  
   308  	return getOCIManifest(ctx, digest, repo)
   309  }
   310  
   311  func (c *nativeOCIClient) ResolveRevision(ctx context.Context, revision string, noCache bool) (string, error) {
   312  	digest, err := c.resolveDigest(ctx, revision) // Lookup explicit revision
   313  	if err != nil {
   314  		// If the revision is not a semver constraint, just return the error
   315  		if !versions.IsConstraint(revision) {
   316  			return digest, err
   317  		}
   318  
   319  		tags, err := c.GetTags(ctx, noCache)
   320  		if err != nil {
   321  			return "", fmt.Errorf("error fetching tags: %w", err)
   322  		}
   323  
   324  		// Look to see if revision is a semver constraint
   325  		version, err := versions.MaxVersion(revision, tags)
   326  		if err != nil {
   327  			return "", fmt.Errorf("no version for constraints: %w", err)
   328  		}
   329  		// Look up the digest for the resolved version
   330  		return c.resolveDigest(ctx, version)
   331  	}
   332  
   333  	return digest, nil
   334  }
   335  
   336  func (c *nativeOCIClient) GetTags(ctx context.Context, noCache bool) ([]string, error) {
   337  	indexLock.Lock(c.repoURL)
   338  	defer indexLock.Unlock(c.repoURL)
   339  
   340  	var data []byte
   341  	if !noCache && c.tagsCache != nil {
   342  		if err := c.tagsCache.GetOCITags(c.repoURL, &data); err != nil && !errors.Is(err, cache.ErrCacheMiss) {
   343  			log.Warnf("Failed to load index cache for repo: %s: %s", c.repoLock, err)
   344  		}
   345  	}
   346  
   347  	var tags []string
   348  	if len(data) == 0 {
   349  		start := time.Now()
   350  		result, err := c.tagsFunc(ctx, "")
   351  		if err != nil {
   352  			return nil, fmt.Errorf("failed to get tags: %w", err)
   353  		}
   354  
   355  		for _, tag := range result {
   356  			// By convention: Change underscore (_) back to plus (+) to get valid SemVer
   357  			convertedTag := strings.ReplaceAll(tag, "_", "+")
   358  			tags = append(tags, convertedTag)
   359  		}
   360  
   361  		log.WithFields(
   362  			log.Fields{"seconds": time.Since(start).Seconds(), "repo": c.repoURL},
   363  		).Info("took to get tags")
   364  
   365  		if c.tagsCache != nil {
   366  			if err := c.tagsCache.SetOCITags(c.repoURL, data); err != nil {
   367  				log.Warnf("Failed to store tags list cache for repo: %s: %s", c.repoURL, err)
   368  			}
   369  		}
   370  	} else if err := json.Unmarshal(data, &tags); err != nil {
   371  		return nil, fmt.Errorf("failed to decode tags: %w", err)
   372  	}
   373  
   374  	return tags, nil
   375  }
   376  
   377  // resolveDigest resolves a digest from a tag.
   378  func (c *nativeOCIClient) resolveDigest(ctx context.Context, revision string) (string, error) {
   379  	descriptor, err := c.repo.Resolve(ctx, revision)
   380  	if err != nil {
   381  		return "", fmt.Errorf("cannot get digest for revision %s: %w", revision, err)
   382  	}
   383  
   384  	return descriptor.Digest.String(), nil
   385  }
   386  
   387  func newTLSConfig(creds Creds) (*tls.Config, error) {
   388  	tlsConfig := &tls.Config{InsecureSkipVerify: creds.InsecureSkipVerify}
   389  
   390  	if creds.CAPath != "" {
   391  		caData, err := os.ReadFile(creds.CAPath)
   392  		if err != nil {
   393  			return nil, err
   394  		}
   395  		caCertPool := x509.NewCertPool()
   396  		caCertPool.AppendCertsFromPEM(caData)
   397  		tlsConfig.RootCAs = caCertPool
   398  	}
   399  
   400  	// If a client cert & key is provided then configure TLS config accordingly.
   401  	if len(creds.CertData) > 0 && len(creds.KeyData) > 0 {
   402  		cert, err := tls.X509KeyPair(creds.CertData, creds.KeyData)
   403  		if err != nil {
   404  			return nil, err
   405  		}
   406  		tlsConfig.Certificates = []tls.Certificate{cert}
   407  	}
   408  	//nolint:staticcheck
   409  	tlsConfig.BuildNameToCertificate()
   410  
   411  	return tlsConfig, nil
   412  }
   413  
   414  func fileExists(filePath string) (bool, error) {
   415  	if _, err := os.Stat(filePath); err != nil {
   416  		if os.IsNotExist(err) {
   417  			return false, nil
   418  		}
   419  		return false, err
   420  	}
   421  	return true, nil
   422  }
   423  
   424  // TODO: A content layer could in theory be something that is not a compressed file, e.g a single yaml file or like.
   425  // While IMO the utility in the context of Argo CD is limited, I'd at least like to make it known here and add an extensibility
   426  // point for it in case we decide to loosen the current requirements.
   427  func isContentLayer(mediaType string) bool {
   428  	return isCompressedLayer(mediaType)
   429  }
   430  
   431  func isCompressedLayer(mediaType string) bool {
   432  	// TODO: Is zstd something which is used in the wild? For now let's stick to these suffixes
   433  	return strings.HasSuffix(mediaType, "tar+gzip") || strings.HasSuffix(mediaType, "tar")
   434  }
   435  
   436  func createTarFile(from, to string) error {
   437  	f, err := os.Create(to)
   438  	if err != nil {
   439  		return err
   440  	}
   441  	if _, err = files.Tar(from, nil, nil, f); err != nil {
   442  		_ = os.RemoveAll(to)
   443  	}
   444  	return f.Close()
   445  }
   446  
   447  // saveCompressedImageToPath downloads a remote OCI image on a given digest and stores it as a TAR file in cachedPath.
   448  func saveCompressedImageToPath(ctx context.Context, digest string, repo oras.ReadOnlyTarget, cachedPath string) error {
   449  	tempDir, err := files.CreateTempDir(os.TempDir())
   450  	if err != nil {
   451  		return err
   452  	}
   453  	defer os.RemoveAll(tempDir)
   454  
   455  	store, err := oci.New(tempDir)
   456  	if err != nil {
   457  		return err
   458  	}
   459  
   460  	// Copy remote repo at the given digest to the scratch dir.
   461  	if _, err = oras.Copy(ctx, repo, digest, store, digest, oras.DefaultCopyOptions); err != nil {
   462  		return err
   463  	}
   464  
   465  	// Remove redundant ingest folder; this is an artifact from the oras.Copy call above
   466  	err = os.RemoveAll(path.Join(tempDir, "ingest"))
   467  	if err != nil {
   468  		return err
   469  	}
   470  
   471  	// Save contents to tar file
   472  	return createTarFile(tempDir, cachedPath)
   473  }
   474  
   475  // extractContentToManifestsDir looks up a locally stored OCI image, and extracts the embedded compressed layer which contains
   476  // K8s manifests to a temporary directory
   477  func extractContentToManifestsDir(ctx context.Context, cachedPath, digest string, maxSize int64) (string, error) {
   478  	manifestsDir, err := files.CreateTempDir(os.TempDir())
   479  	if err != nil {
   480  		return manifestsDir, err
   481  	}
   482  
   483  	ociReadOnlyStore, err := oci.NewFromTar(ctx, cachedPath)
   484  	if err != nil {
   485  		return manifestsDir, err
   486  	}
   487  
   488  	tempDir, err := files.CreateTempDir(os.TempDir())
   489  	if err != nil {
   490  		return manifestsDir, err
   491  	}
   492  	defer os.RemoveAll(tempDir)
   493  
   494  	fs, err := newCompressedLayerFileStore(manifestsDir, tempDir, maxSize)
   495  	if err != nil {
   496  		return manifestsDir, err
   497  	}
   498  	defer fs.Close()
   499  
   500  	// copies the whole artifact to the tempdir, here compressedLayerFileStore.Push will be called
   501  	_, err = oras.Copy(ctx, ociReadOnlyStore, digest, fs, digest, oras.DefaultCopyOptions)
   502  	return manifestsDir, err
   503  }
   504  
   505  type compressedLayerExtracterStore struct {
   506  	*file.Store
   507  	dest    string
   508  	maxSize int64
   509  }
   510  
   511  func newCompressedLayerFileStore(dest, tempDir string, maxSize int64) (*compressedLayerExtracterStore, error) {
   512  	f, err := file.New(tempDir)
   513  	if err != nil {
   514  		return nil, err
   515  	}
   516  
   517  	return &compressedLayerExtracterStore{f, dest, maxSize}, nil
   518  }
   519  
   520  func isHelmOCI(mediaType string) bool {
   521  	return mediaType == "application/vnd.cncf.helm.chart.content.v1.tar+gzip"
   522  }
   523  
   524  // 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
   525  // and then renames the temp dir to the directory where the repo-server expects to find k8s manifests.
   526  func (s *compressedLayerExtracterStore) Push(ctx context.Context, desc imagev1.Descriptor, content io.Reader) error {
   527  	if isContentLayer(desc.MediaType) {
   528  		srcDir, err := files.CreateTempDir(os.TempDir())
   529  		if err != nil {
   530  			return err
   531  		}
   532  		defer os.RemoveAll(srcDir)
   533  
   534  		if strings.HasSuffix(desc.MediaType, "tar+gzip") {
   535  			err = files.Untgz(srcDir, content, s.maxSize, false)
   536  		} else {
   537  			err = files.Untar(srcDir, content, s.maxSize, false)
   538  		}
   539  
   540  		if err != nil {
   541  			return fmt.Errorf("could not decompress layer: %w", err)
   542  		}
   543  
   544  		if isHelmOCI(desc.MediaType) {
   545  			infos, err := os.ReadDir(srcDir)
   546  			if err != nil {
   547  				return err
   548  			}
   549  
   550  			// For a Helm chart we expect a single directory
   551  			if len(infos) != 1 || !infos[0].IsDir() {
   552  				return fmt.Errorf("expected 1 directory, found %v", len(infos))
   553  			}
   554  
   555  			// For Helm charts, we will move the contents of the unpacked directory to the root of its final destination
   556  			srcDir, err = securejoin.SecureJoin(srcDir, infos[0].Name())
   557  			if err != nil {
   558  				return err
   559  			}
   560  		}
   561  
   562  		return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, _ error) error {
   563  			if path != srcDir {
   564  				// Calculate the relative path from srcDir
   565  				relPath, err := filepath.Rel(srcDir, path)
   566  				if err != nil {
   567  					return err
   568  				}
   569  
   570  				dstPath, err := securejoin.SecureJoin(s.dest, relPath)
   571  				if err != nil {
   572  					return err
   573  				}
   574  
   575  				// Move the file by renaming it
   576  				if d.IsDir() {
   577  					info, err := d.Info()
   578  					if err != nil {
   579  						return err
   580  					}
   581  
   582  					return os.MkdirAll(dstPath, info.Mode())
   583  				}
   584  
   585  				return os.Rename(path, dstPath)
   586  			}
   587  
   588  			return nil
   589  		})
   590  	}
   591  
   592  	return s.Store.Push(ctx, desc, content)
   593  }
   594  
   595  func getOCIManifest(ctx context.Context, digest string, repo oras.ReadOnlyTarget) (*imagev1.Manifest, error) {
   596  	desc, err := repo.Resolve(ctx, digest)
   597  	if err != nil {
   598  		return nil, fmt.Errorf("error resolving oci repo from digest, %w", err)
   599  	}
   600  
   601  	rc, err := repo.Fetch(ctx, desc)
   602  	if err != nil {
   603  		return nil, fmt.Errorf("error fetching oci manifest for digest %s: %w", digest, err)
   604  	}
   605  
   606  	manifest := imagev1.Manifest{}
   607  	decoder := json.NewDecoder(rc)
   608  	if err = decoder.Decode(&manifest); err != nil {
   609  		return nil, fmt.Errorf("error decoding oci manifest for digest %s: %w", digest, err)
   610  	}
   611  
   612  	return &manifest, nil
   613  }