Skip to content
Open
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
170 changes: 131 additions & 39 deletions src/service/providers/ImageLinkProvider.ts
Comment thread
bwateratmsft marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,128 @@ import { getCurrentContext } from '../utils/ActionContext';
import { yamlRangeToLspRange } from '../utils/yamlRangeToLspRange';
import { ProviderBase } from './ProviderBase';

const dockerHubImageRegex = /^(?<imageName>[.\w-]+)(?<tag>:[.\w-]+)?$/i;
const dockerHubNamespacedImageRegex = /^(?<namespace>[a-z0-9]+)\/(?<imageName>[.\w-]+)(?<tag>:[.\w-]+)?$/i;
const mcrImageRegex = /^mcr.microsoft.com\/(?<namespace>([a-z0-9]+\/)+)(?<imageName>[.\w-]+)(?<tag>:[.\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>): 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-<namespace>-<name>` 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/<owner>/<package>` 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<DocumentLinkParams & ExtendedParams, DocumentLink[] | undefined, never, never> {
public on(params: DocumentLinkParams & ExtendedParams, token: CancellationToken): Promise<DocumentLink[] | undefined> {
Expand Down Expand Up @@ -51,43 +170,16 @@ export class ImageLinkProvider extends ProviderBase<DocumentLinkParams & Extende
}

private static getLinkForImage(image: string, imageTypes: Set<string>): { 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 };
}
}
74 changes: 74 additions & 0 deletions src/test/providers/ImageLinkProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading