From 6f04d160783716a880e3e9ec63cca257c182e70b Mon Sep 17 00:00:00 2001 From: fschurig <11216482+FabianSchurig@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:30:15 +0000 Subject: [PATCH 1/2] Detect registry hosts in image names like Docker does An image name with one slash was always treated as a Docker Hub name, so a private registry was looked up on docker.io first (#811) and a registry host with a port failed to parse. The first segment is a registry host when it contains a dot or a colon, is localhost, or has uppercase letters, matching Docker's splitDockerDomain. Names without a host, including nested paths, are qualified as docker.io. A repository with no extra path segment now parses. The Podman base-image path uses the same check. --- .../containerCollectionsOCI.ts | 2 +- src/spec-node/containerFeatures.ts | 13 ++------ src/spec-node/utils.ts | 23 +++++++++----- src/test/dockerUtils.test.ts | 30 +++++++++++++++++-- 4 files changed, 47 insertions(+), 21 deletions(-) diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 89b8cfc7b..c2f3a84f3 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -223,7 +223,7 @@ export function getRef(output: Log, input: string): OCIRef | undefined { const registry = splitOnSlash[0]; const namespace = splitOnSlash.slice(1, -1).join('/'); - const path = `${namespace}/${id}`; + const path = namespace ? `${namespace}/${id}` : id; if (!regexForPath.exec(path)) { output.write(`Path '${path}' for input '${input}' failed validation. Expected path to match regex '${regexForPath}'.`, LogLevel.Error); diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index eebb793f2..65402e909 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -11,7 +11,7 @@ import { LogLevel, makeLog } from '../spec-utils/log'; import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; import { readLocalFile } from '../spec-utils/pfs'; import { includeAllConfiguredFeatures } from '../spec-utils/product'; -import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig, isBuildxCacheToInline } from './utils'; +import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, hasRegistryHost, SubstitutedConfig, isBuildxCacheToInline } from './utils'; import { isEarlierVersion, parseVersion, runCommandNoPty } from '../spec-common/commonUtils'; import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo, ImageMetadataEntry, imageMetadataLabel, MergedDevContainerConfig } from './imageMetadata'; import { supportsBuildContexts } from './dockerfileUtils'; @@ -467,7 +467,7 @@ export async function updateRemoteUserUID(params: DockerResolverParameters, merg '-f', destDockerfile, '-t', fixedImageName, ...(platform ? ['--platform', platform] : []), - '--build-arg', `BASE_IMAGE=${params.cliVariant === CLIVariant.Podman && !hasRegistryHostname(imageName) ? 'localhost/' : ''}${imageName}`, // Podman: https://github.com/microsoft/vscode-remote-release/issues/9748 + '--build-arg', `BASE_IMAGE=${params.cliVariant === CLIVariant.Podman && !hasRegistryHost(imageName) ? 'localhost/' : ''}${imageName}`, // Podman: https://github.com/microsoft/vscode-remote-release/issues/9748 '--build-arg', `REMOTE_USER=${remoteUser}`, '--build-arg', `NEW_UID=${await cliHost.getuid!()}`, '--build-arg', `NEW_GID=${await cliHost.getgid!()}`, @@ -481,12 +481,3 @@ export async function updateRemoteUserUID(params: DockerResolverParameters, merg } return fixedImageName; } - -function hasRegistryHostname(imageName: string) { - if (imageName.startsWith('localhost/')) { - return true; - } - const dot = imageName.indexOf('.'); - const slash = imageName.indexOf('/'); - return dot !== -1 && slash !== -1 && dot < slash; -} diff --git a/src/spec-node/utils.ts b/src/spec-node/utils.ts index 9a62da347..be948efaa 100644 --- a/src/spec-node/utils.ts +++ b/src/spec-node/utils.ts @@ -379,19 +379,28 @@ export async function inspectImageInRegistry(output: Log, platformInfo: Platform }; } +// Same rule as Docker's splitDockerDomain: https://github.com/distribution/reference +export function hasRegistryHost(name: string): boolean { + const slash = name.indexOf('/'); + if (slash <= 0) { + return false; + } + const registry = name.substring(0, slash); + return registry.includes('.') || registry.includes(':') || registry === 'localhost' || registry !== registry.toLowerCase(); +} + export function qualifyImageName(name: string) { const segments = name.split('/'); if (segments.length === 1) { return `docker.io/library/${name}`; - } else if (segments.length === 2) { - if (name.startsWith('docker.io/')) { - return `docker.io/library/${segments[1]}`; - } else { - return `docker.io/${name}`; - } - } else { + } + if (segments[0] === 'docker.io') { + return segments.length === 2 ? `docker.io/library/${segments[1]}` : name; + } + if (hasRegistryHost(name)) { return name; } + return `docker.io/${name}`; } export interface DevContainerAuthority { diff --git a/src/test/dockerUtils.test.ts b/src/test/dockerUtils.test.ts index c9329d7c2..8b4513dda 100644 --- a/src/test/dockerUtils.test.ts +++ b/src/test/dockerUtils.test.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { createPlainLog, LogLevel, makeLog } from '../spec-utils/log'; -import { inspectImageInRegistry, qualifyImageName } from '../spec-node/utils'; +import { getRef } from '../spec-configuration/containerCollectionsOCI'; +import { hasRegistryHost, inspectImageInRegistry, qualifyImageName } from '../spec-node/utils'; import assert from 'assert'; import { dockerCLI, listContainers, PartialExecParameters, removeContainer, toExecParameters } from '../spec-shutdown/dockerUtils'; import { createCLIParams } from './testUtils'; @@ -42,11 +43,36 @@ describe('Docker utils', function () { assert.ok(config.Config.Cmd); }); + it('detects a registry host', () => { + assert.strictEqual(hasRegistryHost('myregistry:5000/image'), true); + assert.strictEqual(hasRegistryHost('random/image'), false); + }); + it('qualifies docker.io shorthands', async () => { assert.strictEqual(qualifyImageName('ubuntu'), 'docker.io/library/ubuntu'); assert.strictEqual(qualifyImageName('docker.io/ubuntu'), 'docker.io/library/ubuntu'); assert.strictEqual(qualifyImageName('random/image'), 'docker.io/random/image'); - assert.strictEqual(qualifyImageName('foo/random/image'), 'foo/random/image'); + assert.strictEqual(qualifyImageName('foo/random/image'), 'docker.io/foo/random/image'); + }); + + it('keeps an uppercase registry host', () => { + assert.strictEqual(qualifyImageName('Registry/image'), 'Registry/image'); + }); + + it('keeps a localhost registry host', () => { + assert.strictEqual(qualifyImageName('localhost/image'), 'localhost/image'); + assert.strictEqual(qualifyImageName('localhost:5000/ns/image'), 'localhost:5000/ns/image'); + }); + + it('keeps a registry host with a dot', () => { + assert.strictEqual(qualifyImageName('registry.example.com/image'), 'registry.example.com/image'); + }); + + it('resolves a registry host with a port', () => { + const ref = getRef(output, qualifyImageName('registry.example.com:5000/image:tag')); + assert.strictEqual(ref?.registry, 'registry.example.com:5000'); + assert.strictEqual(ref?.path, 'image'); + assert.strictEqual(ref?.version, 'tag'); }); it('protects against concurrent removal', async () => { From 220fe0765b88f2a6263b78f430261721936d06cf Mon Sep 17 00:00:00 2001 From: fschurig <11216482+FabianSchurig@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:39:17 +0000 Subject: [PATCH 2/2] Reject registry-only references that have no repository path. A name with no slash, such as ghcr.io, was accepted once the empty namespace stopped producing a leading slash. Keep that case invalid, and still accept a host plus repository such as registry.example.com:5000/image. --- src/spec-configuration/containerCollectionsOCI.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index c2f3a84f3..fa5a3e0f1 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -223,7 +223,7 @@ export function getRef(output: Log, input: string): OCIRef | undefined { const registry = splitOnSlash[0]; const namespace = splitOnSlash.slice(1, -1).join('/'); - const path = namespace ? `${namespace}/${id}` : id; + const path = splitOnSlash.length < 2 ? `/${id}` : (namespace ? `${namespace}/${id}` : id); if (!regexForPath.exec(path)) { output.write(`Path '${path}' for input '${input}' failed validation. Expected path to match regex '${regexForPath}'.`, LogLevel.Error);