2024-04-15 15:20:56 -05:00
|
|
|
package tuf
|
|
|
|
|
|
|
|
|
|
import (
|
2024-09-09 14:22:17 +01:00
|
|
|
"context"
|
2024-04-15 15:20:56 -05:00
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"path"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
2024-08-30 17:03:29 +01:00
|
|
|
"github.com/distribution/reference"
|
2024-09-09 14:22:17 +01:00
|
|
|
"github.com/docker/attest/internal/useragent"
|
2024-09-02 16:17:50 +01:00
|
|
|
"github.com/docker/attest/oci"
|
2024-04-15 15:20:56 -05:00
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
|
|
|
"github.com/google/go-containerregistry/pkg/crane"
|
|
|
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
|
|
|
"github.com/google/go-containerregistry/pkg/v1/types"
|
|
|
|
|
"github.com/theupdateframework/go-tuf/v2/metadata"
|
2024-08-30 17:03:29 +01:00
|
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/config"
|
2024-04-15 15:20:56 -05:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const (
|
2024-08-01 15:35:15 +01:00
|
|
|
TUFFileNameAnnotation = "tuf.io/filename"
|
2024-04-15 15:20:56 -05:00
|
|
|
)
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
type Role string
|
2024-04-15 15:20:56 -05:00
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
var Roles = []Role{metadata.ROOT, metadata.SNAPSHOT, metadata.TARGETS, metadata.TIMESTAMP}
|
2024-04-15 15:20:56 -05:00
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// RegistryFetcher implements Fetcher.
|
2024-04-15 15:20:56 -05:00
|
|
|
type RegistryFetcher struct {
|
|
|
|
|
httpUserAgent string
|
|
|
|
|
metadataRepo string
|
|
|
|
|
metadataTag string
|
|
|
|
|
targetsRepo string
|
|
|
|
|
cache *ImageCache
|
|
|
|
|
timeout time.Duration
|
2024-08-30 17:03:29 +01:00
|
|
|
cfg *config.UpdaterConfig
|
2024-04-15 15:20:56 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ImageCache struct {
|
|
|
|
|
cache map[string][]byte
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewImageCache() *ImageCache {
|
|
|
|
|
return &ImageCache{
|
|
|
|
|
cache: make(map[string][]byte),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// Get image from cache.
|
2024-04-15 15:20:56 -05:00
|
|
|
func (c *ImageCache) Get(imgRef string) ([]byte, bool) {
|
|
|
|
|
img, found := c.cache[imgRef]
|
|
|
|
|
return img, found
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// Add image to cache.
|
2024-04-15 15:20:56 -05:00
|
|
|
func (c *ImageCache) Put(imgRef string, img []byte) {
|
|
|
|
|
c.cache[imgRef] = img
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type Layer struct {
|
|
|
|
|
Annotations map[string]string `json:"annotations"`
|
|
|
|
|
Digest string `json:"digest"`
|
|
|
|
|
}
|
|
|
|
|
type Layers struct {
|
|
|
|
|
Layers []Layer `json:"layers"`
|
|
|
|
|
Manifests []Layer `json:"manifests"`
|
|
|
|
|
MediaType string `json:"mediaType"`
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-09 14:22:17 +01:00
|
|
|
func NewRegistryFetcher(ctx context.Context, cfg *config.UpdaterConfig) (*RegistryFetcher, error) {
|
2024-08-30 17:03:29 +01:00
|
|
|
ref, err := reference.ParseNormalizedNamed(cfg.RemoteMetadataURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to parse metadata repo: %w", err)
|
|
|
|
|
}
|
|
|
|
|
// add latest tag
|
|
|
|
|
metadataTag := LatestTag
|
|
|
|
|
if tag, ok := ref.(reference.Tagged); ok {
|
|
|
|
|
metadataTag = tag.Tag()
|
|
|
|
|
}
|
|
|
|
|
metadataRepo := ref.Name()
|
|
|
|
|
|
|
|
|
|
targetsRef, err := reference.ParseNormalizedNamed(cfg.RemoteTargetsURL)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to parse targets repo: %w", err)
|
|
|
|
|
}
|
|
|
|
|
targetsRepo := targetsRef.Name()
|
2024-04-15 15:20:56 -05:00
|
|
|
return &RegistryFetcher{
|
2024-08-30 17:03:29 +01:00
|
|
|
// we need to keep these reference so that we can unmangle the URL paths when downloading files
|
2024-09-09 14:22:17 +01:00
|
|
|
cfg: cfg,
|
|
|
|
|
metadataRepo: metadataRepo,
|
|
|
|
|
metadataTag: metadataTag,
|
|
|
|
|
targetsRepo: targetsRepo,
|
|
|
|
|
cache: NewImageCache(),
|
|
|
|
|
httpUserAgent: useragent.Get(ctx),
|
2024-08-30 17:03:29 +01:00
|
|
|
}, nil
|
2024-04-15 15:20:56 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// DownloadFile downloads a file from an OCI registry, errors out if it failed,
|
|
|
|
|
// its length is larger than maxLength or the timeout is reached.
|
|
|
|
|
func (d *RegistryFetcher) DownloadFile(urlPath string, maxLength int64, timeout time.Duration) ([]byte, error) {
|
|
|
|
|
d.timeout = timeout
|
|
|
|
|
|
|
|
|
|
imgRef, fileName, err := d.parseImgRef(urlPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get manifest for image or index
|
|
|
|
|
mf, err := d.getManifest(imgRef)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Search image/index manifest for file
|
|
|
|
|
hash, err := d.findFileInManifest(mf, fileName)
|
|
|
|
|
if err != nil {
|
|
|
|
|
// TODO - refactor Fetcher interface for not found error handling? (requires go-tuf change)
|
|
|
|
|
return nil, &metadata.ErrDownloadHTTP{StatusCode: http.StatusNotFound}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get file from layer
|
|
|
|
|
parts := strings.Split(imgRef, ":")
|
|
|
|
|
switch len(parts) {
|
|
|
|
|
// default host port
|
|
|
|
|
case 2:
|
|
|
|
|
return d.pullFileLayer(fmt.Sprintf("%s@%s", parts[0], *hash), maxLength)
|
|
|
|
|
// custom host port
|
|
|
|
|
case 3:
|
|
|
|
|
return d.pullFileLayer(fmt.Sprintf("%s:%s@%s", parts[0], parts[1], *hash), maxLength)
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("invalid image reference: %s", imgRef)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// getManifest returns the manifest for an image or index.
|
2024-04-15 15:20:56 -05:00
|
|
|
func (d *RegistryFetcher) getManifest(ref string) ([]byte, error) {
|
|
|
|
|
// Pull image manifest
|
|
|
|
|
var err error
|
|
|
|
|
var found bool
|
|
|
|
|
var mf []byte
|
|
|
|
|
// Check cache for manifest and only pull if not found
|
|
|
|
|
if mf, found = d.cache.Get(ref); !found {
|
|
|
|
|
mf, err = crane.Manifest(ref,
|
|
|
|
|
crane.WithUserAgent(d.httpUserAgent),
|
|
|
|
|
crane.WithTransport(transportWithTimeout(d.timeout)),
|
|
|
|
|
crane.WithAuth(authn.Anonymous),
|
2024-06-18 11:55:30 -05:00
|
|
|
crane.WithAuthFromKeychain(oci.MultiKeychainAll()))
|
2024-04-15 15:20:56 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// Cache the manifest
|
|
|
|
|
d.cache.Put(ref, mf)
|
|
|
|
|
}
|
|
|
|
|
return mf, nil
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// pullFileLayer pulls a layer for an image or index and returns its data.
|
2024-04-15 15:20:56 -05:00
|
|
|
func (d *RegistryFetcher) pullFileLayer(ref string, maxLength int64) ([]byte, error) {
|
|
|
|
|
var data []byte
|
|
|
|
|
var found bool
|
|
|
|
|
// Check cache for layer and only pull if not found
|
|
|
|
|
if data, found = d.cache.Get(ref); !found {
|
|
|
|
|
layer, err := crane.PullLayer(ref,
|
|
|
|
|
crane.WithUserAgent(d.httpUserAgent),
|
|
|
|
|
crane.WithTransport(transportWithTimeout(d.timeout)),
|
|
|
|
|
crane.WithAuth(authn.Anonymous),
|
2024-06-18 11:55:30 -05:00
|
|
|
crane.WithAuthFromKeychain(oci.MultiKeychainAll()))
|
2024-04-15 15:20:56 -05:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
data, err = getDataFromLayer(layer, maxLength)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// Cache the layer
|
|
|
|
|
d.cache.Put(ref, data)
|
|
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// getDataFromLayer returns the data from a layer in an image.
|
2024-04-15 15:20:56 -05:00
|
|
|
func getDataFromLayer(fileLayer v1.Layer, maxLength int64) ([]byte, error) {
|
|
|
|
|
length, err := fileLayer.Size()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// Error if the reported size is greater than what is expected.
|
|
|
|
|
if length > maxLength {
|
|
|
|
|
return nil, &metadata.ErrDownloadLengthMismatch{Msg: fmt.Sprintf("download failed, length %d is larger than expected %d", length, maxLength)}
|
|
|
|
|
}
|
|
|
|
|
content, err := fileLayer.Uncompressed()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
data, err := io.ReadAll(io.LimitReader(content, maxLength+1))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
// Error if the reported size is greater than what is expected.
|
|
|
|
|
length = int64(len(data))
|
|
|
|
|
if length > maxLength {
|
|
|
|
|
return nil, &metadata.ErrDownloadLengthMismatch{Msg: fmt.Sprintf("download failed, length %d is larger than expected %d", length, maxLength)}
|
|
|
|
|
}
|
|
|
|
|
return data, nil
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// parseImgRef maintains the Fetcher interface by parsing a URL path to an image reference and file name.
|
2024-04-15 15:20:56 -05:00
|
|
|
func (d *RegistryFetcher) parseImgRef(urlPath string) (imgRef, fileName string, err error) {
|
|
|
|
|
// Check if repo is target or metadata
|
2024-08-30 17:03:29 +01:00
|
|
|
if strings.HasPrefix(urlPath, d.cfg.RemoteTargetsURL) {
|
2024-04-15 15:20:56 -05:00
|
|
|
// determine if the target path contains subdirectories and set image name accordingly
|
|
|
|
|
// <repo>/<filename> -> image = <repo>:<filename>, layer = <filename>
|
|
|
|
|
// <repo>/<subdir>/<filename> -> index = <repo>:<subdir> , image = <filename> -> layer = <filename>
|
2024-08-30 17:03:29 +01:00
|
|
|
target := strings.TrimPrefix(urlPath, d.cfg.RemoteTargetsURL+"/")
|
2024-04-15 15:20:56 -05:00
|
|
|
subdir, name, found := strings.Cut(target, "/")
|
|
|
|
|
if found {
|
|
|
|
|
return fmt.Sprintf("%s:%s", d.targetsRepo, subdir), fmt.Sprintf("%s/%s", subdir, name), nil
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%s:%s", d.targetsRepo, target), target, nil
|
2024-08-30 17:03:29 +01:00
|
|
|
} else if strings.HasPrefix(urlPath, d.cfg.RemoteMetadataURL) {
|
2024-04-15 15:20:56 -05:00
|
|
|
// build the metadata image name
|
|
|
|
|
// determine if role is a delegated role and set the tag accordingly
|
|
|
|
|
fileName = path.Base(urlPath)
|
|
|
|
|
role := roleFromConsistentName(fileName)
|
|
|
|
|
// if the role is a delegated role use the role name as the tag for imgRef
|
|
|
|
|
if isDelegatedRole(role) {
|
|
|
|
|
return fmt.Sprintf("%s:%s", d.metadataRepo, role), fileName, nil
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf("%s:%s", d.metadataRepo, d.metadataTag), fileName, nil
|
|
|
|
|
}
|
2024-08-01 15:35:15 +01:00
|
|
|
return "", "", fmt.Errorf("urlPath: %s must be in metadata or targets repo", urlPath)
|
2024-04-15 15:20:56 -05:00
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// findFileInManifest searches the image or index manifest for a file with the given name and returns its digest.
|
2024-04-15 15:20:56 -05:00
|
|
|
func (d *RegistryFetcher) findFileInManifest(mf []byte, name string) (*v1.Hash, error) {
|
|
|
|
|
var index bool
|
|
|
|
|
|
|
|
|
|
// unmarshal manifest with annotations
|
|
|
|
|
l := &Layers{}
|
|
|
|
|
err := json.Unmarshal(mf, l)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// determine image or index manifest
|
|
|
|
|
var layers []Layer
|
2024-08-01 15:35:15 +01:00
|
|
|
switch l.MediaType {
|
|
|
|
|
case string(types.OCIImageIndex):
|
2024-04-15 15:20:56 -05:00
|
|
|
layers = l.Manifests
|
|
|
|
|
index = true
|
2024-08-01 15:35:15 +01:00
|
|
|
case string(types.OCIManifestSchema1):
|
2024-04-15 15:20:56 -05:00
|
|
|
layers = l.Layers
|
|
|
|
|
index = false
|
2024-08-01 15:35:15 +01:00
|
|
|
default:
|
2024-04-15 15:20:56 -05:00
|
|
|
return nil, fmt.Errorf("invalid manifest media type: %s", l.MediaType)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// find annotation with file name
|
|
|
|
|
var digest string
|
|
|
|
|
for _, layer := range layers {
|
2024-08-01 15:35:15 +01:00
|
|
|
if layer.Annotations[TUFFileNameAnnotation] == name {
|
2024-04-15 15:20:56 -05:00
|
|
|
digest = layer.Digest
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if digest == "" {
|
|
|
|
|
return nil, fmt.Errorf("file %s not found in image", name)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// return layer digest as v1.Hash
|
|
|
|
|
hash := new(v1.Hash)
|
|
|
|
|
*hash, err = v1.NewHash(digest)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// if index manifest pull image to get file layer
|
|
|
|
|
if index {
|
|
|
|
|
mf, err := d.getManifest(fmt.Sprintf("%s@%s", d.targetsRepo, *hash))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
parts := strings.Split(name, "/")
|
|
|
|
|
return d.findFileInManifest(mf, parts[len(parts)-1])
|
|
|
|
|
}
|
|
|
|
|
return hash, nil
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// transportWithTimeout returns a http.RoundTripper with a specified timeout.
|
2024-04-15 15:20:56 -05:00
|
|
|
func transportWithTimeout(timeout time.Duration) http.RoundTripper {
|
|
|
|
|
// transport is based on go-containerregistry remote.DefaultTransport
|
|
|
|
|
// with modifications to include a specified timeout
|
|
|
|
|
return &http.Transport{
|
|
|
|
|
Proxy: http.ProxyFromEnvironment,
|
|
|
|
|
DialContext: (&net.Dialer{
|
|
|
|
|
Timeout: timeout,
|
|
|
|
|
KeepAlive: timeout,
|
|
|
|
|
}).DialContext,
|
|
|
|
|
ForceAttemptHTTP2: true,
|
|
|
|
|
MaxIdleConns: 100,
|
|
|
|
|
IdleConnTimeout: 90 * time.Second,
|
|
|
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
|
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
|
|
|
MaxIdleConnsPerHost: 50,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// isDelegatedRole returns true if the role is a delegated role.
|
2024-04-15 15:20:56 -05:00
|
|
|
func isDelegatedRole(role string) bool {
|
2024-08-01 15:35:15 +01:00
|
|
|
for _, r := range Roles {
|
2024-04-15 15:20:56 -05:00
|
|
|
if role == string(r) {
|
|
|
|
|
return false // role is not a delegated role
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true // role is a delegated role
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-01 15:35:15 +01:00
|
|
|
// roleFromConsistentName returns the role name from a consistent snapshot file name.
|
2024-04-15 15:20:56 -05:00
|
|
|
func roleFromConsistentName(filename string) string {
|
|
|
|
|
name := strings.TrimSuffix(filename, ".json")
|
|
|
|
|
role := strings.Split(name, ".")
|
|
|
|
|
if len(role) > 1 {
|
|
|
|
|
return role[1]
|
|
|
|
|
}
|
|
|
|
|
return role[0]
|
|
|
|
|
}
|