This is to allow us to store new policy files in the production TUF repository under a testing delegation, and for clients to opt-in to using this testing delegation when retrieving policy from TUF. If the prefix path is set, it is prepended to every target path on download with path.Join. For example, if the prefix path is testing and we download the target a/b, the TUF client with actually download testing/a/b. Also get the latest testdata from tuf-dev.
465 lines
14 KiB
Go
465 lines
14 KiB
Go
package tuf
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/docker/attest/internal/test"
|
|
"github.com/docker/attest/internal/util"
|
|
"github.com/docker/attest/oci"
|
|
"github.com/docker/attest/useragent"
|
|
"github.com/google/go-containerregistry/pkg/crane"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/layout"
|
|
"github.com/google/go-containerregistry/pkg/v1/mutate"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/google/go-containerregistry/pkg/v1/static"
|
|
"github.com/google/go-containerregistry/pkg/v1/types"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/config"
|
|
"github.com/theupdateframework/go-tuf/v2/metadata/updater"
|
|
)
|
|
|
|
const (
|
|
tufTargetMediaType = "application/vnd.tuf.target"
|
|
testRole = "test-role"
|
|
tufMetadataRepo = "tuf-metadata"
|
|
targetsPath = "/tuf-targets"
|
|
metadataPath = "/tuf-metadata"
|
|
targetsRepo = "test" + targetsPath
|
|
)
|
|
|
|
func TestRegistryFetcher(t *testing.T) {
|
|
ctx := context.Background()
|
|
regServer := test.NewLocalRegistry(ctx)
|
|
defer regServer.Close()
|
|
|
|
regAddr, err := url.Parse(regServer.URL)
|
|
require.NoError(t, err)
|
|
|
|
LoadRegistryTestData(ctx, t, regAddr, OCITUFTestDataPath)
|
|
|
|
metadataRepo := regAddr.Host + metadataPath
|
|
targetsRepo := regAddr.Host + targetsPath
|
|
targetFile := "test.txt"
|
|
delegatedRole := testRole
|
|
dir := CreateTempDir(t, "", "tuf_temp")
|
|
delegatedDir := CreateTempDir(t, dir, delegatedRole)
|
|
delegatedTargetFile := fmt.Sprintf("%s/%s", delegatedRole, targetFile)
|
|
|
|
// note - url is ignored here - needed to make http url parsing happy even when using oci
|
|
cfg, err := config.New("", DockerTUFRootDev.Data)
|
|
require.NoError(t, err)
|
|
cfg.LocalMetadataDir = dir
|
|
cfg.LocalTargetsDir = dir
|
|
cfg.RemoteTargetsURL = targetsRepo
|
|
cfg.RemoteMetadataURL = metadataRepo
|
|
cfg.Fetcher, err = NewRegistryFetcher(context.Background(), cfg)
|
|
require.NoError(t, err)
|
|
|
|
// create a new Updater instance
|
|
up, err := updater.New(cfg)
|
|
require.NoError(t, err)
|
|
|
|
// refresh the metadata
|
|
err = up.Refresh()
|
|
require.NoError(t, err)
|
|
|
|
// download top-level target
|
|
targetInfo, err := up.GetTargetInfo(targetFile)
|
|
require.NoError(t, err)
|
|
_, _, err = up.DownloadTarget(targetInfo, filepath.Join(dir, targetInfo.Path), "")
|
|
require.NoError(t, err)
|
|
|
|
// download delegated target
|
|
targetInfo, err = up.GetTargetInfo(delegatedTargetFile)
|
|
require.NoError(t, err)
|
|
_, _, err = up.DownloadTarget(targetInfo, filepath.Join(delegatedDir, targetFile), "")
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestRoleFromConsistentName(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
expected string
|
|
}{
|
|
{"root.json", metadata.ROOT},
|
|
{"1.root.json", metadata.ROOT},
|
|
{"targets.json", metadata.TARGETS},
|
|
{"63.targets.json", metadata.TARGETS},
|
|
{"timestamp", metadata.TIMESTAMP},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.expected, roleFromConsistentName(tc.name))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDelegatedRole(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
expected bool
|
|
}{
|
|
{metadata.ROOT, false},
|
|
{metadata.TARGETS, false},
|
|
{metadata.TIMESTAMP, false},
|
|
{"doi", true},
|
|
{"test", true},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
assert.Equal(t, tc.expected, isDelegatedRole(tc.name))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFindFileInManifest(t *testing.T) {
|
|
// make test image manifest
|
|
file := "test.json"
|
|
data := []byte("test")
|
|
hash := v1.Hash{Hex: util.SHA256Hex(data)}
|
|
img := empty.Image
|
|
img = mutate.MediaType(img, types.OCIManifestSchema1)
|
|
img = mutate.ConfigMediaType(img, types.OCIConfigJSON)
|
|
// add test layer
|
|
name := strings.Join([]string{hash.Hex, file}, ".")
|
|
ann := map[string]string{TUFFileNameAnnotation: name}
|
|
layer := mutate.Addendum{Layer: static.NewLayer(data, tufTargetMediaType), Annotations: ann}
|
|
img, err := mutate.Append(img, layer)
|
|
assert.NoError(t, err)
|
|
imageManifest, err := img.RawManifest()
|
|
assert.NoError(t, err)
|
|
|
|
// make test index manifest
|
|
idx := v1.ImageIndex(empty.Index)
|
|
assert.NoError(t, err)
|
|
// append image to index with annotation
|
|
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
|
|
Add: img,
|
|
Descriptor: v1.Descriptor{
|
|
Annotations: map[string]string{
|
|
TUFFileNameAnnotation: name,
|
|
},
|
|
},
|
|
})
|
|
indexManifest, err := idx.RawManifest()
|
|
assert.NoError(t, err)
|
|
// cache image layer
|
|
d := &RegistryFetcher{
|
|
cache: NewImageCache(),
|
|
targetsRepo: targetsRepo,
|
|
}
|
|
imgHash, err := img.Digest()
|
|
assert.NoError(t, err)
|
|
d.cache.Put(fmt.Sprintf("%s@%s", targetsRepo, imgHash.String()), imageManifest)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
manifest []byte
|
|
file string
|
|
expected string
|
|
}{
|
|
{"consistent filename image", imageManifest, fmt.Sprintf("%s.%s", hash.Hex, file), hash.Hex},
|
|
{"filename image", imageManifest, file, ""},
|
|
{"consistent filename index", indexManifest, fmt.Sprintf("%s.%s", hash.Hex, file), hash.Hex},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
digest, err := d.findFileInManifest(tc.manifest, tc.file)
|
|
if tc.expected == "" {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tc.expected, digest.Hex)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestParseImgRef(t *testing.T) {
|
|
metadataRepo := "test" + metadataPath
|
|
metadataTag := LatestTag
|
|
delegatedRole := testRole
|
|
validRef := fmt.Sprintf("%s/2.root.json", metadataRepo)
|
|
expectedRef := fmt.Sprintf("docker.io/%s:%s", metadataRepo, metadataTag)
|
|
testCases := []struct {
|
|
name string
|
|
ref string
|
|
expectedRef string
|
|
expectedFile string
|
|
metadataRepo string
|
|
metadataTag string
|
|
expectedRefError string
|
|
expectedConstructorError string
|
|
targetsRepo string
|
|
}{
|
|
{name: "top-level metadata", ref: validRef, expectedRef: expectedRef, expectedFile: "2.root.json"},
|
|
{name: "short metdata repo", ref: validRef, metadataRepo: "test" + metadataPath, expectedRef: expectedRef, expectedFile: "2.root.json"},
|
|
{name: "library path", ref: fmt.Sprintf("test%s/2.root.json", metadataPath), metadataRepo: "test" + metadataPath, expectedRef: "docker.io/test/tuf-metadata:latest", expectedFile: "2.root.json"},
|
|
{name: "short targets repo", ref: validRef, targetsRepo: "test" + targetsPath, expectedRef: expectedRef, expectedFile: "2.root.json"},
|
|
{name: "delegated metadata", ref: fmt.Sprintf("%s/%s/5.test-role.json", metadataRepo, delegatedRole), expectedRef: fmt.Sprintf("docker.io/%s:%s", metadataRepo, delegatedRole), expectedFile: "5.test-role.json"},
|
|
{name: "top-level target", ref: fmt.Sprintf("%s/policy.yaml", targetsRepo), expectedRef: fmt.Sprintf("docker.io/%s:policy.yaml", targetsRepo), expectedFile: "policy.yaml"},
|
|
{name: "delegated target", ref: fmt.Sprintf("%s/%s/policy.yaml", targetsRepo, delegatedRole), expectedRef: fmt.Sprintf("docker.io/%s:%s", targetsRepo, delegatedRole), expectedFile: fmt.Sprintf("%s/policy.yaml", delegatedRole)},
|
|
{name: "docker/targets", ref: fmt.Sprintf("%s/2.root.json", "docker.io/docker/targets"), expectedRef: "docker.io/docker/targets:latest", expectedFile: "2.root.json", metadataRepo: "docker.io/docker/targets"},
|
|
{name: "malformed ref", ref: fmt.Sprintf("%s/2.root.json", "@broken"), expectedRefError: "urlPath: @broken/2.root.json must be in metadata or targets repo"},
|
|
{name: "malformed metadataRepo", ref: validRef, metadataRepo: "@broken", expectedConstructorError: "failed to parse metadata repo: invalid reference format"},
|
|
{name: "malformed targetsRepo", ref: validRef, targetsRepo: "@broken", expectedConstructorError: "failed to parse targets repo: invalid reference format"},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
repo := metadataRepo
|
|
if tc.metadataRepo != "" {
|
|
repo = tc.metadataRepo
|
|
}
|
|
targets := targetsRepo
|
|
if tc.targetsRepo != "" {
|
|
targets = tc.targetsRepo
|
|
}
|
|
cfg := &config.UpdaterConfig{
|
|
RemoteMetadataURL: repo,
|
|
RemoteTargetsURL: targets,
|
|
}
|
|
d, err := NewRegistryFetcher(context.Background(), cfg)
|
|
if tc.expectedConstructorError != "" {
|
|
assert.ErrorContains(t, err, tc.expectedConstructorError)
|
|
} else {
|
|
require.NoError(t, err)
|
|
imgRef, file, err := d.parseImgRef(tc.ref)
|
|
if tc.expectedRefError != "" {
|
|
assert.ErrorContains(t, err, tc.expectedRefError)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tc.expectedRef, imgRef, "ref mismatch")
|
|
assert.Equal(t, tc.expectedFile, file, "file mismatch")
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetDataFromLayer(t *testing.T) {
|
|
data := []byte("test")
|
|
layer := static.NewLayer(data, tufTargetMediaType)
|
|
testCases := []struct {
|
|
name string
|
|
layer v1.Layer
|
|
max int64
|
|
expected []byte
|
|
}{
|
|
{"valid length", layer, int64(len(data)), data},
|
|
{"invalid length", layer, int64(len(data) - 1), nil},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
data, err := getDataFromLayer(tc.layer, tc.max)
|
|
if tc.expected == nil {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tc.expected, data)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPullFileLayer(t *testing.T) {
|
|
ctx := context.Background()
|
|
regServer := test.NewLocalRegistry(ctx)
|
|
defer regServer.Close()
|
|
|
|
url, err := url.Parse(regServer.URL)
|
|
require.NoError(t, err)
|
|
|
|
// make test layer
|
|
repo := tufMetadataRepo
|
|
data := []byte("test")
|
|
testLayer := static.NewLayer(data, tufTargetMediaType)
|
|
hash, err := testLayer.Digest()
|
|
assert.NoError(t, err)
|
|
layerRef := fmt.Sprintf("%s/%s@%s", url.Host, repo, hash.String())
|
|
|
|
// cache test manifest
|
|
d, err := NewRegistryFetcher(context.Background(), &config.UpdaterConfig{
|
|
RemoteMetadataURL: tufMetadataRepo,
|
|
RemoteTargetsURL: tufMetadataRepo,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
d.cache.Put(layerRef, data)
|
|
|
|
// push uncached image layer to local registry
|
|
uncachedData := []byte("uncached")
|
|
uncachedTestLayer := static.NewLayer(uncachedData, tufTargetMediaType)
|
|
uncachedHash, err := uncachedTestLayer.Digest()
|
|
assert.NoError(t, err)
|
|
uncachedLayerRef := fmt.Sprintf("%s/%s@%s", url.Host, repo, uncachedHash.String())
|
|
img := empty.Image
|
|
img, err = mutate.Append(img, mutate.Addendum{Layer: uncachedTestLayer})
|
|
assert.NoError(t, err)
|
|
err = crane.Push(img, fmt.Sprintf("%s/%s", url.Host, fmt.Sprintf("%s:latest", repo)), crane.WithUserAgent(useragent.Get(ctx)))
|
|
assert.NoError(t, err)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
ref string
|
|
maxLength int
|
|
expected []byte
|
|
}{
|
|
{"cached layer", layerRef, len(data), data},
|
|
{"uncached layer", uncachedLayerRef, len(uncachedData), uncachedData},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
layer, err := d.pullFileLayer(tc.ref, int64(tc.maxLength))
|
|
if tc.expected == nil {
|
|
assert.Error(t, err)
|
|
return
|
|
}
|
|
assert.NoError(t, err)
|
|
assert.Greater(t, len(layer), 0)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetManifest(t *testing.T) {
|
|
ctx := context.Background()
|
|
regServer := test.NewLocalRegistry(ctx)
|
|
defer regServer.Close()
|
|
|
|
url, err := url.Parse(regServer.URL)
|
|
require.NoError(t, err)
|
|
|
|
// make test manifest
|
|
repo := tufMetadataRepo
|
|
img := empty.Image
|
|
img = mutate.MediaType(img, types.OCIManifestSchema1)
|
|
img = mutate.ConfigMediaType(img, types.OCIConfigJSON)
|
|
imgRef := fmt.Sprintf("%s/%s:latest", url.Host, repo)
|
|
|
|
// cache test manifest
|
|
d, err := NewRegistryFetcher(context.Background(), &config.UpdaterConfig{
|
|
RemoteMetadataURL: tufMetadataRepo,
|
|
RemoteTargetsURL: tufMetadataRepo,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
mf, err := img.RawManifest()
|
|
assert.NoError(t, err)
|
|
d.cache.Put(imgRef, mf)
|
|
|
|
// push test image to local registry
|
|
unchachedImgRef := fmt.Sprintf("%s/%s:unchached", url.Host, repo)
|
|
err = crane.Push(img, unchachedImgRef, crane.WithUserAgent(useragent.Get(ctx)))
|
|
assert.NoError(t, err)
|
|
|
|
testCases := []struct {
|
|
name string
|
|
ref string
|
|
expected []byte
|
|
}{
|
|
{"cached image manifest", imgRef, mf},
|
|
{"uncached image manifest", unchachedImgRef, mf},
|
|
}
|
|
for _, tc := range testCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
manifest, err := d.getManifest(tc.ref)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, tc.expected, manifest)
|
|
})
|
|
}
|
|
}
|
|
|
|
// LoadRegistryTestData pushes TUF metadata and targets to an OCI registry.
|
|
func LoadRegistryTestData(ctx context.Context, t *testing.T, registry *url.URL, path string) {
|
|
// push tuf metadata and targets to local registry
|
|
MetadataRepo := tufMetadataRepo
|
|
TargetsRepo := "tuf-targets"
|
|
DelegatedRole := testRole
|
|
|
|
// push top-level metadata -> metadata:latest
|
|
err := LoadMetadata(ctx, filepath.Join(path, "metadata"), registry.Host, MetadataRepo, LatestTag)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// push delegated metadata -> metadata:<DELEGATED_ROLE>
|
|
err = LoadMetadata(ctx, filepath.Join(path, "metadata", DelegatedRole), registry.Host, MetadataRepo, DelegatedRole)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// push targets -> targets:<HASH>.<FILE>.ext (image) or targets:<DELEGATED ROLE> <index)
|
|
targetDirs, err := os.ReadDir(filepath.Join(path, "targets"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, dir := range targetDirs {
|
|
if !dir.IsDir() {
|
|
continue
|
|
}
|
|
tIdx, err := layout.ImageIndexFromPath(filepath.Join(path, "targets", dir.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, err := name.ParseReference(fmt.Sprintf("%s/%s:%s", registry.Host, TargetsRepo, dir.Name()))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mf, err := tIdx.IndexManifest()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
switch {
|
|
case len(mf.Manifests) == 1:
|
|
// top-level target
|
|
img, err := tIdx.Image(mf.Manifests[0].Digest)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = remote.Write(ref, img, oci.WithOptions(ctx, nil)...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
case len(mf.Manifests) > 1:
|
|
// delegated target
|
|
err = remote.WriteIndex(ref, tIdx, oci.WithOptions(ctx, nil)...)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
default:
|
|
t.Fatal("no manifests found")
|
|
}
|
|
}
|
|
}
|
|
|
|
// LoadMetadata loads TUF metadata from a local path and pushes to a registry.
|
|
func LoadMetadata(ctx context.Context, path, host, repo, tag string) error {
|
|
mIdx, err := layout.ImageIndexFromPath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ref, err := name.ParseReference(fmt.Sprintf("%s/%s:%s", host, repo, tag))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mf, err := mIdx.IndexManifest()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
img, err := mIdx.Image(mf.Manifests[0].Digest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return remote.Write(ref, img, oci.WithOptions(ctx, nil)...)
|
|
}
|