Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions cmd/oci/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package main

import (
"bytes"
"context"
"fmt"
"os"
Expand Down Expand Up @@ -43,8 +44,19 @@ func normalizeRef(ref string) (name.Reference, error) {
return name.ParseReference(ref, name.WeakValidation)
}

func imagePlatform(img v1.Image) (v1.Platform, error) {
config, err := img.ConfigFile()
func imagePlatform(ctx context.Context, s *store, img v1.Image) (v1.Platform, error) {
manifest, err := img.Manifest()
if err != nil {
return v1.Platform{}, err
}
raw, err := s.verifiedBlobBytes(ctx, manifest.Config)
if os.IsNotExist(err) {
raw, err = img.RawConfigFile()
}
if err != nil {
return v1.Platform{}, err
}
config, err := v1.ParseConfigFile(bytes.NewReader(raw))
if err != nil {
return v1.Platform{}, err
}
Expand All @@ -55,15 +67,15 @@ func imagePlatform(img v1.Image) (v1.Platform, error) {
}, nil
}

func indexChildImage(index v1.ImageIndex, child v1.Descriptor) (v1.Image, v1.Platform, error) {
func indexChildImage(ctx context.Context, s *store, index v1.ImageIndex, child v1.Descriptor) (v1.Image, v1.Platform, error) {
if !child.MediaType.IsImage() {
return nil, v1.Platform{}, fmt.Errorf("unexpected index child media type %s", child.MediaType)
}
img, err := index.Image(child.Digest)
if err != nil {
return nil, v1.Platform{}, err
}
actual, err := imagePlatform(img)
actual, err := imagePlatform(ctx, s, img)
return img, actual, err
}

Expand All @@ -82,7 +94,7 @@ func imagePlatformMatches(actual, requested v1.Platform) bool {
actual.Variant == requested.Variant
}

func selectIndexImage(index v1.ImageIndex, platform v1.Platform) (v1.Image, error) {
func selectIndexImage(ctx context.Context, s *store, index v1.ImageIndex, platform v1.Platform) (v1.Image, error) {
manifest, err := index.IndexManifest()
if err != nil {
return nil, err
Expand All @@ -91,7 +103,7 @@ func selectIndexImage(index v1.ImageIndex, platform v1.Platform) (v1.Image, erro
if child.Platform == nil || !imagePlatformMatches(*child.Platform, platform) {
continue
}
img, actual, err := indexChildImage(index, child)
img, actual, err := indexChildImage(ctx, s, index, child)
if err != nil {
return nil, err
}
Expand All @@ -104,7 +116,7 @@ func selectIndexImage(index v1.ImageIndex, platform v1.Platform) (v1.Image, erro
if child.Platform != nil {
continue
}
img, actual, err := indexChildImage(index, child)
img, actual, err := indexChildImage(ctx, s, index, child)
if err != nil {
return nil, err
}
Expand All @@ -115,13 +127,13 @@ func selectIndexImage(index v1.ImageIndex, platform v1.Platform) (v1.Image, erro
return nil, fmt.Errorf("no image for platform %s", platform)
}

func selectImage(desc *remote.Descriptor, platform v1.Platform) (v1.Image, error) {
func selectImage(ctx context.Context, s *store, desc *remote.Descriptor, platform v1.Platform) (v1.Image, error) {
if desc.MediaType.IsIndex() {
index, err := desc.ImageIndex()
if err != nil {
return nil, err
}
return selectIndexImage(index, platform)
return selectIndexImage(ctx, s, index, platform)
}
if !desc.MediaType.IsImage() {
return nil, fmt.Errorf("unexpected media type %s", desc.MediaType)
Expand All @@ -130,7 +142,7 @@ func selectImage(desc *remote.Descriptor, platform v1.Platform) (v1.Image, error
if err != nil {
return nil, err
}
actual, err := imagePlatform(img)
actual, err := imagePlatform(ctx, s, img)
if err != nil {
return nil, err
}
Expand All @@ -142,7 +154,7 @@ func selectImage(desc *remote.Descriptor, platform v1.Platform) (v1.Image, error

type imageFetcher func(context.Context, name.Reference, v1.Platform) (v1.Image, error)

func fetchRemoteImage(ctx context.Context, ref name.Reference, platform v1.Platform) (v1.Image, error) {
func fetchRemoteImage(ctx context.Context, s *store, ref name.Reference, platform v1.Platform) (v1.Image, error) {
desc, err := remote.Get(ref,
remote.WithContext(ctx),
remote.WithAuthFromKeychain(authn.DefaultKeychain),
Expand All @@ -151,11 +163,13 @@ func fetchRemoteImage(ctx context.Context, ref name.Reference, platform v1.Platf
if err != nil {
return nil, err
}
return selectImage(desc, platform)
return selectImage(ctx, s, desc, platform)
}

func pullImage(ctx context.Context, s *store, ref string, platform ocispec.Platform) error {
return pullImageWithFetcher(ctx, s, ref, platform, fetchRemoteImage)
return pullImageWithFetcher(ctx, s, ref, platform, func(ctx context.Context, ref name.Reference, platform v1.Platform) (v1.Image, error) {
return fetchRemoteImage(ctx, s, ref, platform)
})
}

func pullImageWithFetcher(ctx context.Context, s *store, ref string, platform ocispec.Platform, fetch imageFetcher) error {
Expand Down
202 changes: 196 additions & 6 deletions cmd/oci/pull_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
package main

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -43,6 +47,190 @@ func imageDigest(t *testing.T, img v1.Image) v1.Hash {
return digest
}

type configRegistry struct {
ref string
body []byte
desc v1.Descriptor
gets atomic.Int32
blocked atomic.Bool
}

func newConfigRegistry(t *testing.T, actual v1.Platform, mode string, sizeDelta int64) *configRegistry {
t.Helper()
authDir := t.TempDir()
if err := os.WriteFile(filepath.Join(authDir, "config.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("DOCKER_CONFIG", authDir)
img := platformImage(t, actual)
body, err := img.RawConfigFile()
if err != nil {
t.Fatal(err)
}
manifest, err := img.Manifest()
if err != nil {
t.Fatal(err)
}
manifest.Config.Size += sizeDelta
r := &configRegistry{body: body, desc: manifest.Config}
imageBytes := mustJSON(t, manifest)
digest, size, err := v1.SHA256(bytes.NewReader(imageBytes))
if err != nil {
t.Fatal(err)
}
entry, media := imageBytes, types.OCIManifestSchema1
if mode != "image" {
child := v1.Descriptor{MediaType: media, Digest: digest, Size: size}
if mode == "declared-index" {
child.Platform = toV1Platform(defaultPlatform)
}
media = types.OCIImageIndex
entry = mustJSON(t, v1.IndexManifest{SchemaVersion: 2, MediaType: media, Manifests: []v1.Descriptor{child}})
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/v2/":
case "/v2/image/manifests/latest":
w.Header().Set("Content-Type", string(media))
_, _ = w.Write(entry)
case "/v2/image/manifests/" + digest.String():
w.Header().Set("Content-Type", string(types.OCIManifestSchema1))
_, _ = w.Write(imageBytes)
case "/v2/image/blobs/" + r.desc.Digest.String():
if req.Method == http.MethodGet {
r.gets.Add(1)
}
if r.blocked.Load() {
http.NotFound(w, req)
return
}
_, _ = w.Write(r.body)
default:
http.NotFound(w, req)
}
}))
t.Cleanup(server.Close)
r.ref = strings.TrimPrefix(server.URL, "http://") + "/image:latest"
return r
}

func (r *configRegistry) pull(t *testing.T, s *store) error {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return pullImage(ctx, s, r.ref, defaultPlatform)
}

func TestPullReusesCachedConfig(t *testing.T) {
for _, mode := range []string{"image", "declared-index", "platformless-index"} {
t.Run(mode, func(t *testing.T) {
r := newConfigRegistry(t, *toV1Platform(defaultPlatform), mode, 0)
s := tempStore(t)
if err := r.pull(t, s); err != nil {
t.Fatal(err)
}
if got := r.gets.Load(); got != 1 {
t.Fatalf("cold config GETs = %d, want 1", got)
}
r.blocked.Store(true)
if err := r.pull(t, s); err != nil {
t.Errorf("cached pull with unavailable source: %v", err)
}
if got := r.gets.Load(); got != 1 {
t.Errorf("config GETs after cached pull = %d, want 1", got)
}
if err := os.Remove(filepath.Join(s.root, "blobs", r.desc.Digest.Algorithm, r.desc.Digest.Hex)); err != nil {
t.Fatal(err)
}
before := r.gets.Load()
if err := r.pull(t, s); err == nil || !strings.Contains(err.Error(), "404") {
t.Fatalf("missing config source error = %v", err)
}
if got := r.gets.Load(); got != before+1 {
t.Fatalf("missing config GETs = %d, want %d", got, before+1)
}
})
}
}

func TestPullCachedConfigPlatformChecks(t *testing.T) {
for _, mode := range []string{"image", "declared-index", "platformless-index"} {
for _, actual := range []v1.Platform{
{OS: "windows", Architecture: "arm64"},
{OS: "linux", Architecture: "amd64"},
{OS: "linux", Architecture: "arm64", Variant: "v9"},
} {
t.Run(mode+"/"+actual.String(), func(t *testing.T) {
r := newConfigRegistry(t, actual, mode, 0)
s := tempStore(t)
pushBlob(t, s, r.desc.MediaType, r.body)
r.blocked.Store(true)
err := r.pull(t, s)
if err == nil || (!strings.Contains(err.Error(), "image platform") && !strings.Contains(err.Error(), "no image for platform")) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The error assertion accepts either "image platform" or "no image for platform" for every mode, but in "image" and "declared-index" modes only "image platform" is reachable (the child is always declared as the requested platform). A selectivity/regression bug that makes the declared path fall through to "no image for platform" — or skips the cached config check — would still pass. Pin the expected message per mode: "no image for platform" for "platformless-index", "image platform" for the other two.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cmd/oci/pull_test.go, line 169:

<comment>The error assertion accepts either "image platform" or "no image for platform" for every mode, but in "image" and "declared-index" modes only "image platform" is reachable (the child is always declared as the requested platform). A selectivity/regression bug that makes the declared path fall through to "no image for platform" — or skips the cached config check — would still pass. Pin the expected message per mode: "no image for platform" for "platformless-index", "image platform" for the other two.</comment>

<file context>
@@ -43,6 +47,190 @@ func imageDigest(t *testing.T, img v1.Image) v1.Hash {
+				pushBlob(t, s, r.desc.MediaType, r.body)
+				r.blocked.Store(true)
+				err := r.pull(t, s)
+				if err == nil || (!strings.Contains(err.Error(), "image platform") && !strings.Contains(err.Error(), "no image for platform")) {
+					t.Errorf("cached platform mismatch error = %v", err)
+				}
</file context>

t.Errorf("cached platform mismatch error = %v", err)
}
if got := r.gets.Load(); got != 0 {
t.Errorf("cached platform check config GETs = %d, want 0", got)
}
})
}
}
}

func TestPullCachedConfigIntegrity(t *testing.T) {
for _, sizeDelta := range []int64{0, 1} {
label := "content"
if sizeDelta != 0 {
label = "size"
}
t.Run(label, func(t *testing.T) {
r := newConfigRegistry(t, *toV1Platform(defaultPlatform), "image", sizeDelta)
s := tempStore(t)
pushBlob(t, s, r.desc.MediaType, r.body)
if sizeDelta == 0 {
path := filepath.Join(s.root, "blobs", r.desc.Digest.Algorithm, r.desc.Digest.Hex)
if err := os.Chmod(path, 0o600); err != nil {
t.Fatal(err)
}
corrupt := bytes.Replace(r.body, []byte("layers"), []byte("layert"), 1)
if bytes.Equal(corrupt, r.body) || !json.Valid(corrupt) {
t.Fatal("corrupt fixture must change the digest while retaining valid JSON")
}
if err := os.WriteFile(path, corrupt, 0o600); err != nil {
t.Fatal(err)
}
}
r.blocked.Store(true)
if err := r.pull(t, s); err == nil {
t.Error("invalid cached config was accepted")
}
if got := r.gets.Load(); got != 0 {
t.Errorf("invalid cached config GETs = %d, want 0", got)
}
})
}
}

func TestPullConfigMismatchLeavesCacheEmpty(t *testing.T) {
for _, mode := range []string{"image", "declared-index", "platformless-index"} {
t.Run(mode, func(t *testing.T) {
r := newConfigRegistry(t, v1.Platform{OS: "linux", Architecture: "amd64"}, mode, 0)
s := tempStore(t)
err := r.pull(t, s)
if err == nil || (!strings.Contains(err.Error(), "image platform") && !strings.Contains(err.Error(), "no image for platform")) {
t.Fatalf("cold platform mismatch error = %v", err)
}
if got := r.gets.Load(); got != 1 {
t.Errorf("cold platform check config GETs = %d, want 1", got)
}
path := filepath.Join(s.root, "blobs", r.desc.Digest.Algorithm, r.desc.Digest.Hex)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("rejected config was persisted: %v", err)
}
})
}
}

func TestNormalizeRef(t *testing.T) {
sha := "sha256:" + strings.Repeat("0", 64)
for _, tc := range []struct{ in, want string }{
Expand Down Expand Up @@ -88,6 +276,7 @@ func TestPullFlagParsing(t *testing.T) {
}

func TestSelectIndexImage(t *testing.T) {
ctx, s := context.Background(), tempStore(t)
arm := v1.Platform{OS: "linux", Architecture: "arm64"}
amd := v1.Platform{OS: "linux", Architecture: "amd64"}
armImage := platformImage(t, arm)
Expand All @@ -96,7 +285,7 @@ func TestSelectIndexImage(t *testing.T) {
mutate.IndexAddendum{Add: amdImage},
mutate.IndexAddendum{Add: armImage},
)
got, err := selectIndexImage(index, arm)
got, err := selectIndexImage(ctx, s, index, arm)
if err != nil {
t.Fatal(err)
}
Expand All @@ -107,12 +296,13 @@ func TestSelectIndexImage(t *testing.T) {
declared := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: amdImage, Descriptor: v1.Descriptor{Platform: &amd}},
)
if _, err := selectIndexImage(declared, arm); err == nil || !strings.Contains(err.Error(), "no image") {
if _, err := selectIndexImage(ctx, s, declared, arm); err == nil || !strings.Contains(err.Error(), "no image") {
t.Fatalf("no-match error = %v", err)
}
}

func TestSelectIndexImagePlatformRules(t *testing.T) {
ctx, s := context.Background(), tempStore(t)
arm := v1.Platform{OS: "linux", Architecture: "arm64"}
armV8 := v1.Platform{OS: "linux", Architecture: "arm64", Variant: "v8"}
amd := v1.Platform{OS: "linux", Architecture: "amd64"}
Expand All @@ -121,29 +311,29 @@ func TestSelectIndexImagePlatformRules(t *testing.T) {
v8Index := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: v8Image, Descriptor: v1.Descriptor{Platform: &armV8}},
)
if _, err := selectIndexImage(v8Index, arm); err != nil {
if _, err := selectIndexImage(ctx, s, v8Index, arm); err != nil {
t.Fatalf("arm64/v8 selection: %v", err)
}

amdV3 := v1.Platform{OS: "linux", Architecture: "amd64", Variant: "v3"}
v3Index := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: platformImage(t, amdV3), Descriptor: v1.Descriptor{Platform: &amdV3}},
)
if _, err := selectIndexImage(v3Index, amd); err == nil || !strings.Contains(err.Error(), "no image") {
if _, err := selectIndexImage(ctx, s, v3Index, amd); err == nil || !strings.Contains(err.Error(), "no image") {
t.Fatalf("amd64/v3 selection error = %v", err)
}

mismatch := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: platformImage(t, amd), Descriptor: v1.Descriptor{Platform: &arm}},
)
if _, err := selectIndexImage(mismatch, arm); err == nil || !strings.Contains(err.Error(), "image platform") {
if _, err := selectIndexImage(ctx, s, mismatch, arm); err == nil || !strings.Contains(err.Error(), "image platform") {
t.Fatalf("config mismatch error = %v", err)
}

nested := mutate.AppendManifests(empty.Index,
mutate.IndexAddendum{Add: empty.Index, Descriptor: v1.Descriptor{Platform: &arm}},
)
if _, err := selectIndexImage(nested, arm); err == nil || !strings.Contains(err.Error(), "index child") {
if _, err := selectIndexImage(ctx, s, nested, arm); err == nil || !strings.Contains(err.Error(), "index child") {
t.Fatalf("nested-index error = %v", err)
}
}
Expand Down
Loading
Loading