From 0b7d6d559643d1fab2ecb56f4e9be248516840e6 Mon Sep 17 00:00:00 2001 From: Shalabh Gupta Date: Thu, 28 May 2026 13:35:19 +0530 Subject: [PATCH] fix(image-link): parse image refs explicitly and add GHCR/Quay support Refactor the regex-based matcher in ImageLinkProvider into an explicit Docker image-reference parser that follows the registry-resolution rules documented in github.com/distribution/reference: a path component before the first '/' is treated as a registry hostname when it contains '.', contains ':', or is exactly 'localhost'. The previous matchers worked correctly for the public Docker Hub / namespaced / MCR cases but silently produced no link for any image on another registry, even well-known public ones. With the new parser: - Docker Hub official, namespaced, and MCR cases keep their existing link targets and ranges (all 119 prior tests pass unchanged). - ghcr.io//[:tag] now links to the package page on github.com (.../pkgs/container/). - quay.io//[:tag] now links to its repository page. - Unrecognized private registries (e.g. nrt.vultrcr.com/..., localhost:5000/..., registry.gitlab.com/...) explicitly return no link rather than relying on the namespace-regex incidentally rejecting them. Adds three new test cases covering GHCR, Quay, and the unrecognized private-registry scenario from issue #179. Fixes #179 --- src/service/providers/ImageLinkProvider.ts | 170 ++++++++++++++----- src/test/providers/ImageLinkProvider.test.ts | 74 ++++++++ 2 files changed, 205 insertions(+), 39 deletions(-) diff --git a/src/service/providers/ImageLinkProvider.ts b/src/service/providers/ImageLinkProvider.ts index 7a1c3bd..698d593 100644 --- a/src/service/providers/ImageLinkProvider.ts +++ b/src/service/providers/ImageLinkProvider.ts @@ -10,9 +10,128 @@ import { getCurrentContext } from '../utils/ActionContext'; import { yamlRangeToLspRange } from '../utils/yamlRangeToLspRange'; import { ProviderBase } from './ProviderBase'; -const dockerHubImageRegex = /^(?[.\w-]+)(?:[.\w-]+)?$/i; -const dockerHubNamespacedImageRegex = /^(?[a-z0-9]+)\/(?[.\w-]+)(?:[.\w-]+)?$/i; -const mcrImageRegex = /^mcr.microsoft.com\/(?([a-z0-9]+\/)+)(?[.\w-]+)(?:[.\w-]+)?$/i; +// A path component (registry hostname, namespace segment, or repository name). +// Hostnames may include dots; repository names may include dots, underscores, dashes. +const PATH_COMPONENT_REGEX = /^[\w][.\w-]*$/; +const TAG_REGEX = /^[.\w-]+$/; + +interface ParsedImageRef { + /** Registry hostname (e.g. 'ghcr.io', 'mcr.microsoft.com'), or undefined for Docker Hub. */ + registry: string | undefined; + /** Namespace path between registry and repository (e.g. 'library', 'owner', 'dotnet/core'), or undefined. */ + namespace: string | undefined; + /** Repository / image name (e.g. 'alpine', 'sdk'). */ + imageName: string; + /** Optional tag (without the leading colon). */ + tag: string | undefined; + /** Length of everything except the optional ':tag' suffix — i.e. the part the link should cover. */ + pathLength: number; +} + +/** + * A path component before the first `/` is treated as a registry hostname iff it + * contains a `.` (domain), contains a `:` (host:port), or is exactly `localhost`. + * This matches Docker's reference resolution rules. + * @see https://github.com/distribution/reference + */ +function isRegistryHost(component: string): boolean { + return component.includes('.') || component.includes(':') || component === 'localhost'; +} + +export function parseImageRef(image: string): ParsedImageRef | undefined { + if (!image || /\s/.test(image)) { + return undefined; + } + + // Split off optional :tag — the tag is the suffix after the last ':' in the + // final '/'-separated component (so a ':' inside a registry host:port portion + // is not mistaken for a tag separator). + const lastSlash = image.lastIndexOf('/'); + const nameAndTag = lastSlash >= 0 ? image.substring(lastSlash + 1) : image; + const pathPrefix = lastSlash >= 0 ? image.substring(0, lastSlash) : ''; + + const tagColon = nameAndTag.indexOf(':'); + const imageName = tagColon >= 0 ? nameAndTag.substring(0, tagColon) : nameAndTag; + const tag = tagColon >= 0 ? nameAndTag.substring(tagColon + 1) : undefined; + + if (!PATH_COMPONENT_REGEX.test(imageName)) { + return undefined; + } + if (tag !== undefined && !TAG_REGEX.test(tag)) { + return undefined; + } + + const pathLength = image.length - (tag !== undefined ? tag.length + 1 : 0); + + const pathParts = pathPrefix ? pathPrefix.split('/') : []; + + // Reject path components that don't look like valid hostname/namespace segments. + // Registry hosts can include `:` (for port); namespace segments cannot. + for (let i = 0; i < pathParts.length; i++) { + const part = pathParts[i]; + const allowColon = i === 0 && isRegistryHost(part); + const partRegex = allowColon ? /^[\w][.\w:-]*$/ : PATH_COMPONENT_REGEX; + if (!partRegex.test(part)) { + return undefined; + } + } + + let registry: string | undefined; + let namespace: string | undefined; + + if (pathParts.length === 0) { + // Plain `alpine` — Docker Hub official image. + } else if (isRegistryHost(pathParts[0])) { + registry = pathParts[0]; + if (pathParts.length > 1) { + namespace = pathParts.slice(1).join('/'); + } + } else { + // No explicit registry — everything is a Docker Hub namespace path. + // Docker Hub only supports a single namespace level (`user/repo`), so reject deeper paths. + if (pathParts.length > 1) { + return undefined; + } + namespace = pathParts[0]; + } + + return { registry, namespace, imageName, tag, pathLength }; +} + +function buildLinkUri(ref: ParsedImageRef, imageTypes: Set): string | undefined { + // Docker Hub — no registry hostname in the reference. + if (ref.registry === undefined) { + if (ref.namespace === undefined) { + imageTypes.add('dockerHub'); + return `https://hub.docker.com/_/${ref.imageName}`; + } + imageTypes.add('dockerHubNamespaced'); + return `https://hub.docker.com/r/${ref.namespace}/${ref.imageName}`; + } + + // Microsoft Container Registry — images are mirrored to a Docker Hub page + // under the `microsoft--` convention. + if (ref.registry === 'mcr.microsoft.com' && ref.namespace !== undefined) { + imageTypes.add('mcr'); + return `https://hub.docker.com/_/microsoft-${ref.namespace.replace(/\//g, '-')}-${ref.imageName}`; + } + + // GitHub Container Registry — link to the package page on github.com. + // Only the `ghcr.io//` form maps cleanly; deeper paths are skipped. + if (ref.registry === 'ghcr.io' && ref.namespace !== undefined && !ref.namespace.includes('/')) { + imageTypes.add('ghcr'); + return `https://github.com/${ref.namespace}/${ref.imageName}/pkgs/container/${ref.imageName}`; + } + + // Quay.io — link to the public repository page. + if (ref.registry === 'quay.io' && ref.namespace !== undefined && !ref.namespace.includes('/')) { + imageTypes.add('quay'); + return `https://quay.io/repository/${ref.namespace}/${ref.imageName}`; + } + + return undefined; +} + export class ImageLinkProvider extends ProviderBase { public on(params: DocumentLinkParams & ExtendedParams, token: CancellationToken): Promise { @@ -51,43 +170,16 @@ export class ImageLinkProvider extends ProviderBase): { uri: string, start: number, length: number } | undefined { - let match: RegExpExecArray | null; - let namespace: string | undefined; - let imageName: string | undefined; - - if ((match = dockerHubImageRegex.exec(image)) && - (imageName = match.groups?.imageName)) { - - imageTypes.add('dockerHub'); + const ref = parseImageRef(image); + if (!ref) { + return undefined; + } - return { - uri: `https://hub.docker.com/_/${imageName}`, - start: match.index, - length: imageName.length - }; - } else if ((match = dockerHubNamespacedImageRegex.exec(image)) && - (namespace = match.groups?.namespace) && - (imageName = match.groups?.imageName)) { - - imageTypes.add('dockerHubNamespaced'); - - return { - uri: `https://hub.docker.com/r/${namespace}/${imageName}`, - start: match.index, - length: namespace.length + 1 + imageName.length // 1 is the length of the '/' after namespace - }; - } else if ((match = mcrImageRegex.exec(image)) && - (namespace = match.groups?.namespace?.replace(/\/$/, '')) && - (imageName = match.groups?.imageName)) { - - imageTypes.add('mcr'); - - return { - uri: `https://hub.docker.com/_/microsoft-${namespace.replace('/', '-')}-${imageName}`, - start: match.index, - length: 18 + namespace.length + 1 + imageName.length // 18 is the length of 'mcr.microsoft.com/', 1 is the length of the '/' after namespace - }; + const uri = buildLinkUri(ref, imageTypes); + if (!uri) { + return undefined; } - return undefined; + + return { uri, start: 0, length: ref.pathLength }; } } diff --git a/src/test/providers/ImageLinkProvider.test.ts b/src/test/providers/ImageLinkProvider.test.ts index 58e54fe..0c1a4a0 100644 --- a/src/test/providers/ImageLinkProvider.test.ts +++ b/src/test/providers/ImageLinkProvider.test.ts @@ -139,6 +139,80 @@ services: await requestImageLinksAndCompare(testConnection, uri, expected); }); + it('Should provide links for GitHub Container Registry images', async () => { + const testObject = { + services: { + a: { + image: 'ghcr.io/microsoft/playwright-mcp' + }, + b: { + image: 'ghcr.io/owner/repo:v1.2.3' + }, + } + }; + + const expected = [ + { + range: Range.create(2, 11, 2, 43), + target: 'https://github.com/microsoft/playwright-mcp/pkgs/container/playwright-mcp' + }, + { + range: Range.create(4, 11, 4, 29), + target: 'https://github.com/owner/repo/pkgs/container/repo' + }, + ]; + + const uri = testConnection.sendObjectAsYamlDocument(testObject); + await requestImageLinksAndCompare(testConnection, uri, expected); + }); + + it('Should provide links for Quay.io images', async () => { + const testObject = { + services: { + a: { + image: 'quay.io/prometheus/node-exporter' + }, + b: { + image: 'quay.io/coreos/etcd:v3.5.0' + }, + } + }; + + const expected = [ + { + range: Range.create(2, 11, 2, 43), + target: 'https://quay.io/repository/prometheus/node-exporter' + }, + { + range: Range.create(4, 11, 4, 30), + target: 'https://quay.io/repository/coreos/etcd' + }, + ]; + + const uri = testConnection.sendObjectAsYamlDocument(testObject); + await requestImageLinksAndCompare(testConnection, uri, expected); + }); + + it('Should NOT provide links for unrecognized private registries', async () => { + // Reproduces the scenario reported in https://github.com/microsoft/compose-language-service/issues/179 + const testObject = { + services: { + a: { + image: 'nrt.vultrcr.com/wulicoco/code-sync' + }, + b: { + image: 'localhost:5000/myimg' + }, + c: { + image: 'registry.gitlab.com/group/project/image' + }, + } + }; + + const uri = testConnection.sendObjectAsYamlDocument(testObject); + await requestImageLinksAndCompare(testConnection, uri, []); + }); + it('Should NOT provide links for services with `build` section', async () => { const testObject = { services: {