Files
zot/pkg/meta/common/common.go
T
Gianluca Boiano f2064c9af0 fix: prevent nil pointer dereference in RemoveImageFromRepoMeta (#3658)
* fix: prevent nil pointer dereference in RemoveImageFromRepoMeta

This commit fixes a critical bug where RemoveImageFromRepoMeta crashes with
a nil pointer dereference during retention policy execution and GC operations.

Root Cause:
The function was accessing blob metadata without checking if it exists first.
During GC/retention operations, the metadata database might have stale
references to blobs that no longer exist, causing runtime panics.

Changes:
- Added nil check for descriptorBlobInfo before accessing LastUpdated field
- Added nil check for blobInfo before dereferencing Size, Vendors, Platforms, and SubBlobs
- Made the function consistent with recalculateAggregateFields which already had these checks

Impact:
- Fixes crashes during retention policy execution
- Fixes crashes during GC manifest removal
- Fixes image deletion failures via API
- Eliminates need for dryRun: true workaround in retention config

The fix gracefully handles missing blob metadata by skipping those entries
instead of crashing.

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

* test: add comprehensive tests for RemoveImageFromRepoMeta nil checks

Add test coverage for the nil pointer dereference fixes in RemoveImageFromRepoMeta.
These tests ensure the function handles missing blob metadata gracefully during
GC and retention operations.

Test cases:
- Handle nil blob info for descriptor digest (line 280 check)
- Handle nil blob info in queue traversal (line 297 check)
- Verify correct behavior with valid blob info
- Handle empty tags edge case
- Skip tags with empty digest

Coverage: RemoveImageFromRepoMeta now has 100% test coverage

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

* test: fix RemoveImageFromRepoMeta tests to match actual usage

Address review feedback:
- Delete tag from repoMeta.Tags before calling RemoveImageFromRepoMeta
- Fix blob count expectations after tag removal
- Add assertion to verify tag was removed from metadata
- Update comments to clarify expected behavior

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

* test: add tag removal assertion to second test case

Add missing assertion to verify tag1 was removed from resultMeta.Tags
in the 'should handle nil blob info in queue traversal' test.

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

* refactor: improve nil blob handling documentation and test coverage

Address Copilot review feedback:
- Expand comment at line 278 to explain implications of skipping tags
  with missing blob info, clarifying that metadata inconsistency is
  acceptable in GC/cleanup scenarios
- Revise 'should handle nil blob info for descriptor digest' test to
  cover more realistic scenario: remove tag1 while tag2 has missing
  blob info, demonstrating graceful handling of data inconsistencies
  in remaining tags during removal operations

All tests pass with 49 total assertions.

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

* fix: prevent nil pointer in GetCandidates when statistics missing

Add defensive check in pkg/retention/candidate.go to handle cases where
a tag exists in repoMeta.Tags but has no corresponding entry in
repoMeta.Statistics. This prevents incorrect retention decisions based
on zero-value timestamps.

Changes:
- Check statistics existence before creating candidates
- Skip tags with missing statistics (retained by GetRetainedTagsFromMetaDB)
- Improve performance from O(n*m) to O(n) by using direct map lookup
- Add comprehensive test coverage for missing statistics scenarios

This addresses the concern raised in PR #3658 about metadata
inconsistencies due to non-transactional writes to blob store and metaDB.

Related: #3658
Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

* test: achieve 100% coverage for RemoveImageFromRepoMeta nil checks

Enhance test coverage for RemoveImageFromRepoMeta to address codecov failures
by adding comprehensive test cases that exercise all code paths including nil
pointer checks and continue statements.

Changes:
- Enhanced 'nil blob info for descriptor digest' test to verify processing
  continues with other valid tags after skipping nil entries
- Enhanced 'nil blob info in queue traversal' test to handle mixed valid/nil
  sub-blobs and verify correct processing continuation
- Added 'multiple nil blobs in deeply nested structure' test to cover complex
  scenarios with multiple missing blobs at various nesting levels
- Enhanced 'skip tags with empty digest' test to verify processing continues
  with valid tags after skipping empty digest entries
- Added 'combined edge cases' test to verify all edge cases work together:
  empty digest, nil descriptor blob, and nil queue blob

Coverage Results:
- RemoveImageFromRepoMeta: 100.0% line coverage (was 87.50%)
- All 7 test scenarios pass with 75 total assertions
- All nil check code paths fully exercised
- All continue statement behaviors validated

Fixes codecov/patch failure on PR #3658 where 2 lines were missing coverage.

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>

---------

Signed-off-by: Gianluca Boiano <morf3089@gmail.com>
2025-12-17 11:44:51 +02:00

428 lines
12 KiB
Go

package common
import (
"slices"
"strings"
"time"
godigest "github.com/opencontainers/go-digest"
ispec "github.com/opencontainers/image-spec/specs-go/v1"
zerr "zotregistry.dev/zot/v2/errors"
zcommon "zotregistry.dev/zot/v2/pkg/common"
mConvert "zotregistry.dev/zot/v2/pkg/meta/convert"
proto_go "zotregistry.dev/zot/v2/pkg/meta/proto/gen"
mTypes "zotregistry.dev/zot/v2/pkg/meta/types"
)
func SignatureAlreadyExists(signatureSlice []mTypes.SignatureInfo, sm mTypes.SignatureMetadata) bool {
return slices.ContainsFunc(signatureSlice, func(sigInfo mTypes.SignatureInfo) bool {
return sm.SignatureDigest == sigInfo.SignatureManifestDigest
})
}
func ProtoSignatureAlreadyExists(signatureSlice []*proto_go.SignatureInfo, sm mTypes.SignatureMetadata) bool {
return slices.ContainsFunc(signatureSlice, func(sigInfo *proto_go.SignatureInfo) bool {
return sm.SignatureDigest == sigInfo.SignatureManifestDigest
})
}
func ReferenceIsDigest(reference string) bool {
_, err := godigest.Parse(reference)
return err == nil
}
func ReferenceIsTag(reference string) bool {
return !ReferenceIsDigest(reference)
}
func ValidateRepoReferenceInput(repo, reference string, manifestDigest godigest.Digest) error {
if repo == "" {
return zerr.ErrEmptyRepoName
}
if reference == "" {
return zerr.ErrEmptyTag
}
if manifestDigest == "" {
return zerr.ErrEmptyDigest
}
return nil
}
// These constants are meant used to describe how high or low in rank a match is.
// Note that the "higher rank" relates to a lower number so ranks are sorted in a
// ascending order.
const (
lowPriority = 100
mediumPriority = 10
highPriority = 1
perfectMatchPriority = 0
)
// RankRepoName associates a rank to a given repoName given a searchText.
// The importance of the value grows inversely proportional to the int value it has.
// For example: rank(1) > rank(10) > rank(100)...
func RankRepoName(searchText string, repoName string) int {
searchText = strings.Trim(searchText, "/")
searchTextSlice := strings.Split(searchText, "/")
repoNameSlice := strings.Split(repoName, "/")
if len(searchTextSlice) > len(repoNameSlice) {
return -1
}
if searchText == repoName {
return perfectMatchPriority
}
// searchText contains just 1 directory name
if len(searchTextSlice) == 1 {
lastNameInRepoPath := repoNameSlice[len(repoNameSlice)-1]
// searchText: "bar" | repoName: "foo/bar" lastNameInRepoPath: "bar"
if index := strings.Index(lastNameInRepoPath, searchText); index != -1 {
return (index + 1) * highPriority
}
firstNameInRepoPath := repoNameSlice[0]
// searchText: "foo" | repoName: "foo/bar" firstNameInRepoPath: "foo"
if index := strings.Index(firstNameInRepoPath, searchText); index != -1 {
return (index + 1) * mediumPriority
}
}
foundPrefixInRepoName := true
// searchText: "foo/bar/rep" | repoName: "foo/bar/baz/repo" foundPrefixInRepoName: true
// searchText: "foo/baz/rep" | repoName: "foo/bar/baz/repo" foundPrefixInRepoName: false
for i := 0; i < len(searchTextSlice)-1; i++ {
if searchTextSlice[i] != repoNameSlice[i] {
foundPrefixInRepoName = false
break
}
}
if foundPrefixInRepoName {
lastNameInRepoPath := repoNameSlice[len(repoNameSlice)-1]
lastNameInSearchText := searchTextSlice[len(searchTextSlice)-1]
// searchText: "foo/bar/epo" | repoName: "foo/bar/baz/repo" -> Index(repo, epo) = 1
if index := strings.Index(lastNameInRepoPath, lastNameInSearchText); index != -1 {
return (index + 1) * highPriority
}
}
// searchText: "foo/bar/b" | repoName: "foo/bar/baz/repo"
if strings.HasPrefix(repoName, searchText) {
return mediumPriority
}
// searchText: "bar/ba" | repoName: "foo/bar/baz/repo"
if index := strings.Index(repoName, searchText); index != -1 {
return (index + 1) * lowPriority
}
// no match
return -1
}
func GetRepoTag(searchText string) (string, string, error) {
const repoTagCount = 2
splitSlice := strings.Split(searchText, ":")
if len(splitSlice) != repoTagCount {
return "", "", zerr.ErrInvalidRepoRefFormat
}
repo := strings.TrimSpace(splitSlice[0])
tag := strings.TrimSpace(splitSlice[1])
return repo, tag, nil
}
func MatchesArtifactTypes(descriptorMediaType string, artifactTypes []string) bool {
if len(artifactTypes) == 0 {
return true
}
return slices.ContainsFunc(artifactTypes, func(artifactType string) bool {
return artifactType == "" || descriptorMediaType == artifactType
})
}
// CheckImageLastUpdated check if the given image is updated earlier than the current repoLastUpdated value
//
// It returns updated values for: repoLastUpdated, noImageChecked, isSigned.
func CheckImageLastUpdated(repoLastUpdated time.Time, isSigned bool, noImageChecked bool,
manifestFilterData mTypes.FilterData,
) (time.Time, bool, bool) {
if noImageChecked || repoLastUpdated.Before(manifestFilterData.LastUpdated) {
repoLastUpdated = manifestFilterData.LastUpdated
noImageChecked = false
isSigned = manifestFilterData.IsSigned
}
return repoLastUpdated, noImageChecked, isSigned
}
func AddImageMetaToRepoMeta(repoMeta *proto_go.RepoMeta, repoBlobs *proto_go.RepoBlobs, reference string,
imageMeta mTypes.ImageMeta,
) (*proto_go.RepoMeta, *proto_go.RepoBlobs) {
switch imageMeta.MediaType {
case ispec.MediaTypeImageManifest:
manifestData := imageMeta.Manifests[0]
vendor := GetVendor(manifestData.Manifest.Annotations)
if vendor == "" {
vendor = GetVendor(manifestData.Manifest.Annotations)
}
vendors := []string{}
if vendor != "" {
vendors = append(vendors, vendor)
}
platforms := []*proto_go.Platform{GetProtoPlatform(&manifestData.Config.Platform)}
if platforms[0].OS == "" && platforms[0].Architecture == "" {
platforms = []*proto_go.Platform{}
}
subBlobs := []string{manifestData.Manifest.Config.Digest.String()}
repoBlobs.Blobs[manifestData.Manifest.Config.Digest.String()] = &proto_go.BlobInfo{
Size: manifestData.Manifest.Config.Size,
}
for _, layer := range manifestData.Manifest.Layers {
subBlobs = append(subBlobs, layer.Digest.String())
repoBlobs.Blobs[layer.Digest.String()] = &proto_go.BlobInfo{Size: layer.Size}
}
lastUpdated := zcommon.GetImageLastUpdated(manifestData.Config)
repoBlobs.Blobs[imageMeta.Digest.String()] = &proto_go.BlobInfo{
Size: imageMeta.Size,
Vendors: vendors,
Platforms: platforms,
SubBlobs: subBlobs,
LastUpdated: mConvert.GetProtoTime(&lastUpdated),
}
case ispec.MediaTypeImageIndex:
subBlobs := []string{}
lastUpdated := time.Time{}
for _, manifest := range imageMeta.Index.Manifests {
subBlobs = append(subBlobs, manifest.Digest.String())
blobInfo := repoBlobs.Blobs[manifest.Digest.String()]
if blobInfo != nil && blobInfo.LastUpdated != nil {
if lastUpdated.Before(blobInfo.LastUpdated.AsTime()) {
lastUpdated = blobInfo.LastUpdated.AsTime()
}
}
}
repoBlobs.Blobs[imageMeta.Digest.String()] = &proto_go.BlobInfo{
Size: imageMeta.Size,
SubBlobs: subBlobs,
LastUpdated: mConvert.GetProtoTime(&lastUpdated),
}
}
// update info only when a tag is added
if zcommon.IsDigest(reference) {
return repoMeta, repoBlobs
}
size, platforms, vendors := recalculateAggregateFields(repoMeta, repoBlobs)
repoMeta.Vendors = vendors
repoMeta.Platforms = platforms
repoMeta.Size = size
imageBlobInfo := repoBlobs.Blobs[imageMeta.Digest.String()]
repoMeta.LastUpdatedImage = mConvert.GetProtoEarlierUpdatedImage(repoMeta.LastUpdatedImage,
&proto_go.RepoLastUpdatedImage{
LastUpdated: imageBlobInfo.LastUpdated,
MediaType: imageMeta.MediaType,
Digest: imageMeta.Digest.String(),
Tag: reference,
})
return repoMeta, repoBlobs
}
func RemoveImageFromRepoMeta(repoMeta *proto_go.RepoMeta, repoBlobs *proto_go.RepoBlobs, ref string,
) (*proto_go.RepoMeta, *proto_go.RepoBlobs) {
var updatedLastImage *proto_go.RepoLastUpdatedImage
updatedBlobs := map[string]*proto_go.BlobInfo{}
updatedSize := int64(0)
updatedVendors := []string{}
updatedPlatforms := []*proto_go.Platform{}
for tag, descriptor := range repoMeta.Tags {
if descriptor.Digest == "" {
continue
}
queue := []string{descriptor.Digest}
// Check if blob info exists before accessing it to prevent nil pointer dereference.
// When a tag is skipped due to nil blob info, that tag remains in repoMeta.Tags but
// won't have any associated blobs in the result, creating metadata inconsistency.
// This is acceptable in GC/cleanup scenarios where data may already be inconsistent
// due to partial deletions or corruption.
descriptorBlobInfo := repoBlobs.Blobs[descriptor.Digest]
if descriptorBlobInfo == nil {
continue
}
updatedLastImage = mConvert.GetProtoEarlierUpdatedImage(updatedLastImage, &proto_go.RepoLastUpdatedImage{
LastUpdated: descriptorBlobInfo.LastUpdated,
MediaType: descriptor.MediaType,
Digest: descriptor.Digest,
Tag: tag,
})
for len(queue) > 0 {
currentBlob := queue[0]
queue = queue[1:]
if _, found := updatedBlobs[currentBlob]; !found {
blobInfo := repoBlobs.Blobs[currentBlob]
if blobInfo == nil {
continue
}
updatedBlobs[currentBlob] = blobInfo
updatedSize += blobInfo.Size
updatedVendors = mConvert.AddVendors(updatedVendors, blobInfo.Vendors)
updatedPlatforms = mConvert.AddProtoPlatforms(updatedPlatforms, blobInfo.Platforms)
queue = append(queue, blobInfo.SubBlobs...)
}
}
}
repoMeta.Size = updatedSize
repoMeta.Vendors = updatedVendors
repoMeta.Platforms = updatedPlatforms
repoMeta.LastUpdatedImage = updatedLastImage
repoBlobs.Blobs = updatedBlobs
return repoMeta, repoBlobs
}
func recalculateAggregateFields(repoMeta *proto_go.RepoMeta, repoBlobs *proto_go.RepoBlobs,
) (int64, []*proto_go.Platform, []string) {
size := int64(0)
platforms := []*proto_go.Platform{}
vendors := []string{}
blobsMap := map[string]struct{}{}
for _, descriptor := range repoMeta.Tags {
if descriptor.Digest == "" {
continue
}
queue := []string{descriptor.Digest}
for len(queue) > 0 {
currentBlob := queue[0]
queue = queue[1:]
if _, found := blobsMap[currentBlob]; !found {
blobInfo := repoBlobs.Blobs[currentBlob]
if blobInfo == nil {
continue
}
blobsMap[currentBlob] = struct{}{}
size += blobInfo.Size
vendors = mConvert.AddVendors(vendors, blobInfo.Vendors)
platforms = mConvert.AddProtoPlatforms(platforms, blobInfo.Platforms)
queue = append(queue, blobInfo.SubBlobs...)
}
}
}
return size, platforms, vendors
}
func GetProtoPlatform(platform *ispec.Platform) *proto_go.Platform {
if platform == nil {
return nil
}
return &proto_go.Platform{
Architecture: getArch(platform.Architecture, platform.Variant),
OS: platform.OS,
}
}
func getArch(arch string, variant string) string {
if variant != "" {
arch = arch + "/" + variant
}
return arch
}
func GetVendor(annotations map[string]string) string {
return GetAnnotationValue(annotations, ispec.AnnotationVendor, "org.label-schema.vendor")
}
func GetAnnotationValue(annotations map[string]string, annotationKey, labelKey string) string {
value, ok := annotations[annotationKey]
if !ok || value == "" {
value, ok = annotations[labelKey]
if !ok {
value = ""
}
}
return value
}
func GetPartialImageMeta(imageIndexMeta mTypes.ImageMeta, imageMeta mTypes.ImageMeta) mTypes.ImageMeta {
partialImageMeta := imageIndexMeta
partialImageMeta.Manifests = imageMeta.Manifests
partialIndex := deref(imageIndexMeta.Index, ispec.Index{})
partialIndex.Manifests = getPartialManifestList(partialIndex.Manifests, imageMeta.Digest.String())
partialImageMeta.Index = &partialIndex
return partialImageMeta
}
func getPartialManifestList(descriptors []ispec.Descriptor, manifestDigest string) []ispec.Descriptor {
result := []ispec.Descriptor{}
for i := range descriptors {
if descriptors[i].Digest.String() == manifestDigest {
result = append(result, descriptors[i])
}
}
return result
}
func deref[T any](pointer *T, defaultVal T) T {
if pointer != nil {
return *pointer
}
return defaultVal
}