Skip to content
This repository was archived by the owner on Jul 20, 2026. It is now read-only.
Merged
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
196 changes: 157 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,154 @@ 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-]+$/;
const DIGEST_REGEX = /^[\w.+-]+:[0-9a-f]+$/i;

// Docker Hub's explicit registry hostnames. A reference using any of these is treated
// the same as a bare Docker Hub reference (e.g. `docker.io/library/alpine` === `library/alpine`).
const DOCKER_HUB_REGISTRIES = new Set(['docker.io', 'index.docker.io', 'registry-1.docker.io']);

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), is exactly `localhost`, or
* contains an uppercase character (repository namespaces are always lowercase, so an
* uppercase first component signals a registry host). 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' || component !== component.toLowerCase();
}

export function parseImageRef(image: string): ParsedImageRef | undefined {
if (!image || /\s/.test(image)) {
return undefined;
}

// Split off an optional @digest suffix first (e.g. `@sha256:...`). The digest never
// forms part of the link target, so it is validated then dropped.
const atIndex = image.indexOf('@');
const digest = atIndex >= 0 ? image.substring(atIndex + 1) : undefined;
const ref = atIndex >= 0 ? image.substring(0, atIndex) : image;

if (digest !== undefined && !DIGEST_REGEX.test(digest)) {
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 = ref.lastIndexOf('/');
const nameAndTag = lastSlash >= 0 ? ref.substring(lastSlash + 1) : ref;
const pathPrefix = lastSlash >= 0 ? ref.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;
}

// The link covers the registry/namespace/name path only — excluding both the
// :tag and @digest suffixes.
const pathLength = ref.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 {
// Registry hostnames are case-insensitive; normalize before matching known registries.
const registry = ref.registry?.toLowerCase();

// Docker Hub — either no registry hostname in the reference, or one of Docker Hub's
// explicit hostnames. Docker Hub only supports a single namespace level, so skip deeper paths.
if (registry === undefined || DOCKER_HUB_REGISTRIES.has(registry)) {
if (ref.namespace === undefined) {
imageTypes.add('dockerHub');
return `https://hub.docker.com/_/${ref.imageName}`;
}
if (ref.namespace.includes('/')) {
return undefined;
}
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 (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 (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 (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 +196,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 };
}
}
Loading
Loading