From cff6c1baff5e0bcea49a9369883c75ea255ede37 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Fri, 22 May 2026 11:41:33 +0200 Subject: [PATCH] feat(discovery): tag-agnostic image name normalisation --- internal/discovery/matching.go | 33 +++++++++++++++++++ internal/discovery/matching_test.go | 51 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 internal/discovery/matching.go create mode 100644 internal/discovery/matching_test.go diff --git a/internal/discovery/matching.go b/internal/discovery/matching.go new file mode 100644 index 0000000..c872009 --- /dev/null +++ b/internal/discovery/matching.go @@ -0,0 +1,33 @@ +package discovery + +import "strings" + +// NormaliseImage strips the tag and digest from an image reference, +// returning the bare repository name. +// +// Tricky case: "localhost:5000/foo:v1" — the first colon is a port, +// not a tag. We disambiguate by splitting on "/" first and only +// treating colons in the last segment as tag separators. +func NormaliseImage(ref string) string { + if at := strings.Index(ref, "@"); at >= 0 { + ref = ref[:at] + } + slash := strings.LastIndex(ref, "/") + if slash < 0 { + if colon := strings.Index(ref, ":"); colon >= 0 { + return ref[:colon] + } + return ref + } + prefix, last := ref[:slash], ref[slash+1:] + if colon := strings.Index(last, ":"); colon >= 0 { + last = last[:colon] + } + return prefix + "/" + last +} + +// ImagesMatch reports whether two image references resolve to the same +// repository, ignoring tag and digest. Case-sensitive per spec section 5.2. +func ImagesMatch(a, b string) bool { + return NormaliseImage(a) == NormaliseImage(b) +} diff --git a/internal/discovery/matching_test.go b/internal/discovery/matching_test.go new file mode 100644 index 0000000..5f20fb1 --- /dev/null +++ b/internal/discovery/matching_test.go @@ -0,0 +1,51 @@ +package discovery_test + +import ( + "testing" + + "github.com/shcizo/package-updater/internal/discovery" + "github.com/stretchr/testify/require" +) + +func TestNormaliseImage(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"registry.example.com/myapp", "registry.example.com/myapp"}, + {"registry.example.com/myapp:v1.2.3", "registry.example.com/myapp"}, + {"registry.example.com/myapp:latest", "registry.example.com/myapp"}, + {"registry.example.com/myapp@sha256:abc123", "registry.example.com/myapp"}, + {"registry.example.com/myapp:v1.2.3@sha256:abc123", "registry.example.com/myapp"}, + {"nginx", "nginx"}, + {"nginx:1.25-alpine", "nginx"}, + {"library/nginx:latest", "library/nginx"}, + {"gcr.io/proj/svc:tag", "gcr.io/proj/svc"}, + {"localhost:5000/myimg:v1", "localhost:5000/myimg"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + require.Equal(t, c.want, discovery.NormaliseImage(c.in)) + }) + } +} + +func TestImagesMatch(t *testing.T) { + require.True(t, discovery.ImagesMatch( + "registry.example.com/myapp", + "registry.example.com/myapp:v1.2.3", + )) + require.True(t, discovery.ImagesMatch( + "registry.example.com/myapp:v1.0.0", + "registry.example.com/myapp:v9.9.9", + )) + require.False(t, discovery.ImagesMatch( + "registry.example.com/myapp", + "registry.example.com/otherapp", + )) + // Case-sensitive per spec section 5.2 + require.False(t, discovery.ImagesMatch( + "registry.example.com/MyApp", + "registry.example.com/myapp", + )) +}