diff --git a/CHANGELOG.md b/CHANGELOG.md index 134e0266e..e56c4d920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notable changes. ### [0.88.0] - Add WSLc support (https://github.com/devcontainers/cli/pull/1249) +- Derive the `--userns=keep-id` mapping from the remote user's actual UID/GID when using Podman, so the container user is mapped to the host user even when their UIDs differ (e.g. high UIDs from AD/SSSD). (https://github.com/devcontainers/cli/issues/1284) ## May 2026 diff --git a/src/spec-node/singleContainer.ts b/src/spec-node/singleContainer.ts index 362559c2e..f45b011ef 100644 --- a/src/spec-node/singleContainer.ts +++ b/src/spec-node/singleContainer.ts @@ -410,7 +410,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t ...getLabels(labels), ...containerEnv, ...containerUserArgs, - ...await getPodmanArgs(params, config, mergedConfig, imageDetails), + ...await getPodmanArgs(params, config, mergedConfig, imageName, imageDetails), ...(config.runArgs || []), ...(await extraRunArgs(common, params, config) || []), ...featureArgs, @@ -435,14 +435,18 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t common.output.stop(text, start); } -async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise): Promise { +async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise): Promise { if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') { const args = ['--security-opt', 'label=disable']; const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg)); if (!hasIdMapping) { const remoteUser = mergedConfig.remoteUser || findUserArg(config.runArgs) || (await imageDetails()).Config.User || 'root'; if (remoteUser !== 'root' && remoteUser !== '0') { - args.push('--userns=keep-id'); + // Prefer parsing a numeric user spec directly from config; only fall back to + // running a throwaway container when the user is a name that must be resolved + // from the image's /etc/passwd and /etc/group. + const uidGid = parseNumericUidGid(remoteUser) ?? await resolveRemoteUserUidGid(params, imageName, remoteUser); + args.push(...getKeepIdArgs(uidGid)); } } return args; @@ -450,6 +454,46 @@ async function getPodmanArgs(params: DockerResolverParameters, config: DevContai return []; } +// Parses a user spec (e.g. "1000", "1000:1000", "vscode", "vscode:1000") and returns +// numeric uid/gid only when both parts are numeric. When no group is given, the gid +// defaults to the uid. Returns undefined when the user is a name, in which case the +// caller must resolve the mapping from the image (e.g. via a throwaway container). +export function parseNumericUidGid(remoteUser: string): { uid: string; gid: string } | undefined { + const [user, group] = remoteUser.split(':'); + if (!user || !/^\d+$/.test(user)) { + return undefined; + } + const gid = group ?? user; + if (!/^\d+$/.test(gid)) { + return undefined; + } + return { uid: user, gid }; +} + +// Resolves the remote user's UID and GID inside the image by running a throwaway container. +// Returns undefined if the resolution fails, in which case the caller falls back to plain --userns=keep-id. +export async function resolveRemoteUserUidGid(params: DockerResolverParameters, imageName: string, remoteUser: string): Promise<{ uid: string; gid: string } | undefined> { + try { + const infoParams = { ...toExecParameters(params), output: makeLog(params.common.output, LogLevel.Info) }; + const result = await dockerCLI(infoParams, 'run', '--rm', '--entrypoint', '/bin/sh', imageName, '-c', `id -u ${remoteUser}; id -g ${remoteUser}`); + const [uid, gid] = result.stdout.toString().trim().split(/\r?\n/); + if (uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid)) { + return { uid, gid }; + } + } catch { + // Fall through to plain --userns=keep-id. + } + return undefined; +} + +// Builds the --userns=keep-id argument, using the explicit uid/gid mapping when available. +export function getKeepIdArgs(uidGid: { uid: string; gid: string } | undefined): string[] { + if (uidGid) { + return [`--userns=keep-id:uid=${uidGid.uid},gid=${uidGid.gid}`]; + } + return ['--userns=keep-id']; +} + // Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc. function convertMountToVolume(mountStr: string): string[] { const parts = new Map(mountStr.split(',').map(p => { diff --git a/src/test/cli.podman.test.ts b/src/test/cli.podman.test.ts index 932d9a358..605bf4da7 100644 --- a/src/test/cli.podman.test.ts +++ b/src/test/cli.podman.test.ts @@ -40,5 +40,46 @@ describe('Dev Containers CLI using Podman', function () { assert.ok(containerId, 'Container id not found.'); await shellExec(`podman rm -f ${containerId}`); }); + + it('should map the remote user uid/gid with an explicit --userns=keep-id mapping', async () => { + const testFolder = `${__dirname}/configs/podman-keep-id`; + const res = await shellExec(`${cli} up --docker-path podman --workspace-folder ${testFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + const containerId: string = response.containerId; + assert.ok(containerId, 'Container id not found.'); + + // The container user 'foo' is baked to uid 1234 / gid 4321. With an explicit + // keep-id mapping, files the remote user creates in the bind-mounted workspace + // must be owned by the host user (not by host uid 1234). + const marker = `keepidtest_${Date.now()}`; + await shellExec(`podman exec ${containerId} sh -c "touch /workspaces/cli/${marker}"`); + const hostStat = await shellExec(`stat -c '%u:%g' ${path.join(__dirname, '..', '..', marker)}`); + assert.strictEqual(hostStat.stdout.trim(), `${process.getuid!()}:${process.getgid!()}`); + await shellExec(`rm -f ${path.join(__dirname, '..', '..', marker)}`); + + await shellExec(`podman rm -f ${containerId}`); + }); + + it('should map a numeric remote user uid/gid without resolving from the image', async () => { + const testFolder = `${__dirname}/configs/podman-keep-id-numeric`; + const res = await shellExec(`${cli} up --docker-path podman --workspace-folder ${testFolder}`); + const response = JSON.parse(res.stdout); + assert.equal(response.outcome, 'success'); + const containerId: string = response.containerId; + assert.ok(containerId, 'Container id not found.'); + + // The remote user is specified numerically (1234), so the CLI must derive the + // keep-id mapping directly from config rather than running a throwaway container. + // Files the remote user creates in the bind-mounted workspace must be owned by + // the host user (not by host uid 1234). + const marker = `keepidtest_${Date.now()}`; + await shellExec(`podman exec ${containerId} sh -c "touch /workspaces/cli/${marker}"`); + const hostStat = await shellExec(`stat -c '%u:%g' ${path.join(__dirname, '..', '..', marker)}`); + assert.strictEqual(hostStat.stdout.trim(), `${process.getuid!()}:${process.getgid!()}`); + await shellExec(`rm -f ${path.join(__dirname, '..', '..', marker)}`); + + await shellExec(`podman rm -f ${containerId}`); + }); }); }); \ No newline at end of file diff --git a/src/test/configs/podman-keep-id-numeric/.devcontainer.json b/src/test/configs/podman-keep-id-numeric/.devcontainer.json new file mode 100644 index 000000000..062544eda --- /dev/null +++ b/src/test/configs/podman-keep-id-numeric/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "1234", + "updateRemoteUserUID": false +} diff --git a/src/test/configs/podman-keep-id-numeric/Dockerfile b/src/test/configs/podman-keep-id-numeric/Dockerfile new file mode 100644 index 000000000..5730eb1bb --- /dev/null +++ b/src/test/configs/podman-keep-id-numeric/Dockerfile @@ -0,0 +1,4 @@ +FROM debian:latest + +RUN groupadd -g 4321 foo +RUN useradd -m -u 1234 -g 4321 foo diff --git a/src/test/configs/podman-keep-id/.devcontainer.json b/src/test/configs/podman-keep-id/.devcontainer.json new file mode 100644 index 000000000..0046fd486 --- /dev/null +++ b/src/test/configs/podman-keep-id/.devcontainer.json @@ -0,0 +1,7 @@ +{ + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "foo", + "updateRemoteUserUID": false +} diff --git a/src/test/configs/podman-keep-id/Dockerfile b/src/test/configs/podman-keep-id/Dockerfile new file mode 100644 index 000000000..5730eb1bb --- /dev/null +++ b/src/test/configs/podman-keep-id/Dockerfile @@ -0,0 +1,4 @@ +FROM debian:latest + +RUN groupadd -g 4321 foo +RUN useradd -m -u 1234 -g 4321 foo diff --git a/src/test/keepIdArgs.test.ts b/src/test/keepIdArgs.test.ts new file mode 100644 index 000000000..f9bb47659 --- /dev/null +++ b/src/test/keepIdArgs.test.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { getKeepIdArgs, parseNumericUidGid } from '../spec-node/singleContainer'; + +describe('parseNumericUidGid', function () { + it('should parse a plain numeric uid, defaulting gid to the uid', () => { + assert.deepStrictEqual(parseNumericUidGid('1000'), { uid: '1000', gid: '1000' }); + }); + + it('should parse a numeric uid:gid pair', () => { + assert.deepStrictEqual(parseNumericUidGid('1000:1001'), { uid: '1000', gid: '1001' }); + }); + + it('should return undefined for a named user', () => { + assert.strictEqual(parseNumericUidGid('vscode'), undefined); + }); + + it('should return undefined for a named user with numeric group', () => { + assert.strictEqual(parseNumericUidGid('vscode:1000'), undefined); + }); + + it('should return undefined for a numeric user with named group', () => { + assert.strictEqual(parseNumericUidGid('1000:vscode'), undefined); + }); + + it('should return undefined for an empty or malformed spec', () => { + assert.strictEqual(parseNumericUidGid(''), undefined); + assert.strictEqual(parseNumericUidGid(':1000'), undefined); + }); +}); + +describe('getKeepIdArgs', function () { + it('should return plain --userns=keep-id when uid/gid are not resolved', () => { + assert.deepStrictEqual(getKeepIdArgs(undefined), ['--userns=keep-id']); + }); + + it('should return explicit uid/gid mapping when resolved', () => { + assert.deepStrictEqual( + getKeepIdArgs({ uid: '1000', gid: '1000' }), + ['--userns=keep-id:uid=1000,gid=1000'] + ); + }); + + it('should return explicit mapping for a high (non-bakeable) uid', () => { + assert.deepStrictEqual( + getKeepIdArgs({ uid: '1400601103', gid: '1400600513' }), + ['--userns=keep-id:uid=1400601103,gid=1400600513'] + ); + }); +});