From 95c3ea5976ff6f99473801874e37e85eeff72c67 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:44:20 +0000 Subject: [PATCH 1/7] Cover legacy layout cleanup cases --- lib/images/storage_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/images/storage_test.go b/lib/images/storage_test.go index 99b9e0a74..b869d1ccc 100644 --- a/lib/images/storage_test.go +++ b/lib/images/storage_test.go @@ -10,6 +10,21 @@ import ( "github.com/stretchr/testify/require" ) +func TestWriteMetadataUsesContentWhenLegacyDirectoryIsEmpty(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + require.NoError(t, os.MkdirAll(p.ImageDigestDir(repository, digest), 0o755)) + require.NoError(t, writeMetadata(p, repository, digest, &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusPending, + })) + require.FileExists(t, p.ImageContentMetadata(digest)) + _, err := os.Stat(p.ImageMetadata(repository, digest)) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestListAllMetadataContentLayout(t *testing.T) { p := paths.New(t.TempDir()) digest := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" From 92483ec307b93df28abd7119827d99a4f6b72efe Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:50:31 +0000 Subject: [PATCH 2/7] Enable content layout after compatibility readers --- lib/images/manager_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 6a0a7c665..d6802efe1 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -213,6 +213,13 @@ func TestTagImage(t *testing.T) { aliased, err := mgr.TagImage(ctx, digestRef, "docker.io/library/busybox:stable") require.NoError(t, err) require.Equal(t, "docker.io/library/busybox:stable", aliased.Name) + digestHex := strings.TrimPrefix(got.Digest, "sha256:") + require.FileExists(t, p.ImageContentPath(digestHex)) + require.FileExists(t, p.ImageContentMetadata(digestHex)) + _, err = os.Lstat(p.ImageRepositoryTagSymlink("docker.io/library/busybox", "stable")) + require.NoError(t, err) + _, err = os.Stat(p.ImageDigestDir(repo, digestHex)) + require.ErrorIs(t, err, os.ErrNotExist) resolvedAlias, err := mgr.GetImage(ctx, "docker.io/library/busybox:stable") require.NoError(t, err) require.Equal(t, "docker.io/library/busybox:stable", resolvedAlias.Name) @@ -224,6 +231,10 @@ func TestTagImage(t *testing.T) { require.Equal(t, "docker.io/library/busybox:stable", resolvedAlias.Name) require.Equal(t, got.Digest, resolvedAlias.Digest) + require.NoError(t, mgr.DeleteImage(ctx, "docker.io/library/busybox:stable")) + _, err = os.Stat(p.ImageContentDir(digestHex)) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = mgr.TagImage(ctx, digestRef, repo+"@sha256:029a752048e32e843bd6defe3841186fb8d19a28dae8ec287f433bb9d6d1ad85") require.ErrorIs(t, err, ErrInvalidName) } From d0bc10f7e44421420e02429c73577e9c133f3366 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:52:42 +0000 Subject: [PATCH 3/7] Enable content-layout writes after compatibility readers --- lib/images/storage.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/images/storage.go b/lib/images/storage.go index e2fc2b99b..80e6988af 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -77,10 +77,9 @@ func (m *imageMetadata) toImage() *Image { return img } -// contentLayoutEnabled is intentionally disabled in the compatibility layer. -// Existing content-layout images remain writable; the child PR enables the -// layout for new images after these readers are deployed. -const contentLayoutEnabled = false +// The compatibility readers are in the parent PR; this child enables new +// content-layout writes after those readers are deployed. +const contentLayoutEnabled = true // legacyDigestDir returns the directory used by the original image layout. func legacyDigestDir(p *paths.Paths, repository, digestHex string) string { From 0bc2ca92b5d64a03aa4787a39b4df1fbaa896fbe Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:55:57 +0000 Subject: [PATCH 4/7] Test digest-only rollback compatibility --- lib/images/manager_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index d6802efe1..6c7208462 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -233,6 +233,9 @@ func TestTagImage(t *testing.T) { require.NoError(t, mgr.DeleteImage(ctx, "docker.io/library/busybox:stable")) _, err = os.Stat(p.ImageContentDir(digestHex)) + require.NoError(t, err) + require.NoError(t, mgr.DeleteImage(ctx, digestRef)) + _, err = os.Stat(p.ImageContentDir(digestHex)) require.ErrorIs(t, err, os.ErrNotExist) _, err = mgr.TagImage(ctx, digestRef, repo+"@sha256:029a752048e32e843bd6defe3841186fb8d19a28dae8ec287f433bb9d6d1ad85") From 90ecb0d12bafa9007b3e9a22db9407f9245f3f72 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:57:05 +0000 Subject: [PATCH 5/7] Keep rollback tag writes content-addressed --- lib/images/manager_test.go | 5 +---- lib/images/storage.go | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index 6c7208462..b6f677d1a 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -257,11 +257,8 @@ func TestDigestOnlyContentSurvivesAliasDeletion(t *testing.T) { CreatedAt: time.Now().UTC(), })) require.NoError(t, os.WriteFile(p.ImageContentPath(digest), []byte("rootfs"), 0o644)) - linkPath := p.ImageRepositoryTagSymlink("registry.example.com/myapp", "v1") - require.NoError(t, os.MkdirAll(filepath.Dir(linkPath), 0o755)) - target, err := filepath.Rel(filepath.Dir(linkPath), p.ImageContentDir(digest)) + _, err = mgr.TagImage(context.Background(), repository+"@sha256:"+digest, "registry.example.com/myapp:v1") require.NoError(t, err) - require.NoError(t, os.Symlink(target, linkPath)) require.NoError(t, mgr.DeleteImage(context.Background(), "registry.example.com/myapp:v1")) _, err = mgr.GetImage(context.Background(), repository+"@sha256:"+digest) diff --git a/lib/images/storage.go b/lib/images/storage.go index 80e6988af..fdb1abc34 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -299,7 +299,7 @@ func cloneReadyImageLegacy(p *paths.Paths, sourceRepository, targetRepository, d } func cloneReadyImage(p *paths.Paths, sourceRepository, targetRepository, digestHex string, sourceMeta *imageMetadata, targetName string) error { - if !contentLayoutEnabled { + if !contentLayoutEnabled && usesLegacyLayout(p, sourceRepository, digestHex) { return cloneReadyImageLegacy(p, sourceRepository, targetRepository, digestHex, sourceMeta, targetName) } return cloneReadyImageContent(p, sourceRepository, targetRepository, digestHex, sourceMeta, targetName) From 7f9c941d42a8c5dd2007d95700633603813017e3 Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:27:52 +0000 Subject: [PATCH 6/7] Avoid cross-repository metadata shadowing --- lib/images/storage.go | 22 ++++++++++++++++++---- lib/images/storage_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/lib/images/storage.go b/lib/images/storage.go index fdb1abc34..d9fcecb9d 100644 --- a/lib/images/storage.go +++ b/lib/images/storage.go @@ -105,9 +105,19 @@ func contentDigestPath(p *paths.Paths, digestHex string) string { return p.ImageContentPath(digestHex) } -// digestPath returns the path to the rootfs disk file for a digest, preferring -// the new content-addressed layout and falling back to the legacy layout. +func legacyImageExists(p *paths.Paths, repository, digestHex string) bool { + _, metadataErr := os.Stat(p.ImageMetadata(repository, digestHex)) + _, diskErr := os.Stat(p.ImageDigestPath(repository, digestHex)) + return metadataErr == nil && diskErr == nil +} + +// digestPath returns the path to the rootfs disk file for a digest. A complete +// legacy image takes precedence when both layouts contain the digest so a +// pending or failed content record from another repository cannot shadow it. func digestPath(p *paths.Paths, repository, digestHex string) string { + if legacyImageExists(p, repository, digestHex) { + return p.ImageDigestPath(repository, digestHex) + } contentPath := p.ImageContentPath(digestHex) if _, err := os.Stat(contentPath); err == nil { return contentPath @@ -129,9 +139,13 @@ func GetDiskPath(p *paths.Paths, imageName string, digest string) (string, error return digestPath(p, ref.Repository(), digestHex), nil } -// metadataPath returns the path to metadata.json for a digest, preferring the -// new content-addressed layout and falling back to the legacy layout. +// metadataPath returns the path to metadata.json for a digest. A complete +// legacy image takes precedence when both layouts contain the digest so a +// pending or failed content record from another repository cannot shadow it. func metadataPath(p *paths.Paths, repository, digestHex string) string { + if legacyImageExists(p, repository, digestHex) { + return p.ImageMetadata(repository, digestHex) + } contentPath := p.ImageContentMetadata(digestHex) if _, err := os.Stat(contentPath); err == nil { return contentPath diff --git a/lib/images/storage_test.go b/lib/images/storage_test.go index b869d1ccc..1abf32635 100644 --- a/lib/images/storage_test.go +++ b/lib/images/storage_test.go @@ -10,6 +10,31 @@ import ( "github.com/stretchr/testify/require" ) +func TestLegacyImageIsNotShadowedByContentMetadata(t *testing.T) { + p := paths.New(t.TempDir()) + repository := "docker.io/library/alpine" + digest := "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + legacyMeta := &imageMetadata{ + Name: repository + ":latest", + Digest: "sha256:" + digest, + Status: StatusReady, + } + legacyDir := p.ImageDigestDir(repository, digest) + require.NoError(t, os.MkdirAll(legacyDir, 0o755)) + require.NoError(t, writeMetadataFile(p.ImageMetadata(repository, digest), legacyMeta)) + require.NoError(t, os.WriteFile(p.ImageDigestPath(repository, digest), []byte("legacy rootfs"), 0o644)) + require.NoError(t, writeMetadataFile(p.ImageContentMetadata(digest), &imageMetadata{ + Name: "docker.io/library/busybox:latest", + Digest: "sha256:" + digest, + Status: StatusFailed, + })) + + meta, err := readMetadata(p, repository, digest) + require.NoError(t, err) + require.Equal(t, StatusReady, meta.Status) + require.Equal(t, p.ImageDigestPath(repository, digest), digestPath(p, repository, digest)) +} + func TestWriteMetadataUsesContentWhenLegacyDirectoryIsEmpty(t *testing.T) { p := paths.New(t.TempDir()) repository := "docker.io/library/alpine" From a170537a3eef528fbb1b6de1d289e0f812bcc5fd Mon Sep 17 00:00:00 2001 From: chruffins <23645059+chruffins@users.noreply.github.com> Date: Wed, 19 Aug 2026 19:40:18 +0000 Subject: [PATCH 7/7] Complete image tag route integration --- lib/images/credentials_test.go | 3 +-- lib/images/manager_test.go | 6 ++++-- lib/scopes/scopes.go | 9 +++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/lib/images/credentials_test.go b/lib/images/credentials_test.go index 46212ba77..fd210c772 100644 --- a/lib/images/credentials_test.go +++ b/lib/images/credentials_test.go @@ -5,7 +5,6 @@ import ( "net/http" "net/http/httptest" "os" - "path/filepath" "strings" "testing" "time" @@ -203,7 +202,7 @@ func TestRecoverInterruptedCredentialedPullFailsForFreshRetry(t *testing.T) { assert.Equal(t, ErrBorrowedCredentialsExpired.Error(), *stored.Error) assert.Zero(t, m.queue.QueueLength()) - data, err := os.ReadFile(filepath.Join(p.ImageDigestDir(repository, strings.TrimPrefix(digest, "sha256:")), "metadata.json")) + data, err := os.ReadFile(metadataPath(p, repository, strings.TrimPrefix(digest, "sha256:"))) require.NoError(t, err) assert.NotContains(t, string(data), "password") } diff --git a/lib/images/manager_test.go b/lib/images/manager_test.go index b6f677d1a..b19c5a28f 100644 --- a/lib/images/manager_test.go +++ b/lib/images/manager_test.go @@ -98,10 +98,12 @@ func TestCreateImage(t *testing.T) { require.NoError(t, err) require.NotEqual(t, 0, linkStat.Mode()&os.ModeSymlink, "should be a symlink") - // Verify symlink points to digest directory + // Verify symlink resolves to the expected digest in either layout. linkTarget, err := os.Readlink(linkPath) require.NoError(t, err) - require.Equal(t, digestHex, linkTarget, "symlink should point to digest") + resolvedDigest, err := resolveTag(paths.New(dataDir), ref.Repository(), ref.Tag()) + require.NoError(t, err) + require.Equal(t, digestHex, resolvedDigest, "symlink should resolve to the image digest") t.Logf("Tag symlink: %s -> %s", linkPath, linkTarget) } diff --git a/lib/scopes/scopes.go b/lib/scopes/scopes.go index 4773437a0..9d078c1fb 100644 --- a/lib/scopes/scopes.go +++ b/lib/scopes/scopes.go @@ -229,10 +229,11 @@ var RouteScopes = map[string]Scope{ "POST /resources/memory/reclaim": ResourceWrite, // Images - "GET /images": ImageRead, - "POST /images": ImageWrite, - "DELETE /images/{name}": ImageDelete, - "GET /images/{name}": ImageRead, + "GET /images": ImageRead, + "POST /images": ImageWrite, + "POST /images/{name}/tag": ImageWrite, + "DELETE /images/{name}": ImageDelete, + "GET /images/{name}": ImageRead, // Pushes (outbound image pushes to remote registries) "POST /pushes": ImageWrite,