diff --git a/src/spec-common/commonUtils.ts b/src/spec-common/commonUtils.ts index 9b42484ed..940f2d72b 100644 --- a/src/spec-common/commonUtils.ts +++ b/src/spec-common/commonUtils.ts @@ -14,6 +14,7 @@ import { StringDecoder } from 'string_decoder'; import { toErrorText } from './errors'; import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; import { isLocalFile } from '../spec-utils/pfs'; +import { escapeRegExCharacters } from '../spec-utils/strings'; import { Log, nullLog } from '../spec-utils/log'; import { ShellServer } from './shellServer'; @@ -581,3 +582,11 @@ export async function getLocalUsername() { } return localUsername; } + +export function getEntPasswdShellCommand(userNameOrId: string) { + const escapedForShell = userNameOrId.replace(/['\\]/g, '\\$&'); + const escapedForRexExp = escapeRegExCharacters(userNameOrId) + .replaceAll('\'', '\\\''); + // Leading space makes sure we don't concatenate to arithmetic expansion (https://tldp.org/LDP/abs/html/dblparens.html). + return ` (command -v getent >/dev/null 2>&1 && getent passwd '${escapedForShell}' || grep -E '^${escapedForRexExp}|^[^:]*:[^:]*:${escapedForRexExp}:' /etc/passwd)`; +} diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index f482a5ea4..2f36662f9 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -10,7 +10,7 @@ import * as crypto from 'crypto'; import { ContainerError, toErrorText, toWarningText } from './errors'; import { launch, ShellServer } from './shellServer'; -import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec } from './commonUtils'; +import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec, getEntPasswdShellCommand } from './commonUtils'; import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; import { PackageConfiguration } from '../spec-utils/product'; import { URI } from 'vscode-uri'; @@ -286,7 +286,7 @@ async function getUserShell(containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdU } export async function getUserFromPasswdDB(shellServer: ShellServer, userNameOrId: string) { - const { stdout } = await shellServer.exec(`getent passwd ${userNameOrId}`, { logOutput: false }); + const { stdout } = await shellServer.exec(getEntPasswdShellCommand(userNameOrId), { logOutput: false }); return parseUserInPasswdDB(stdout); } diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 2bc937f51..dda542173 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -21,6 +21,7 @@ import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getPublishedVersi import { Lockfile, readLockfile, writeLockfile } from './lockfile'; import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; import { logFeatureAdvisories } from './featureAdvisories'; +import { getEntPasswdShellCommand } from '../spec-common/commonUtils'; // v1 const V1_ASSET_NAME = 'devcontainer-features.tgz'; @@ -323,8 +324,8 @@ export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`; let result = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd ${containerUser} | cut -d: -f6)" >> ${builtinsEnvFile} && \\ -echo "_REMOTE_USER_HOME=$(getent passwd ${remoteUser} | cut -d: -f6)" >> ${builtinsEnvFile} +echo "_CONTAINER_USER_HOME=$(${getEntPasswdShellCommand(containerUser)} | cut -d: -f6)" >> ${builtinsEnvFile} && \\ +echo "_REMOTE_USER_HOME=$(${getEntPasswdShellCommand(remoteUser)} | cut -d: -f6)" >> ${builtinsEnvFile} `; diff --git a/src/spec-shutdown/dockerUtils.ts b/src/spec-shutdown/dockerUtils.ts index b78d7ed7c..f50e8b4db 100644 --- a/src/spec-shutdown/dockerUtils.ts +++ b/src/spec-shutdown/dockerUtils.ts @@ -8,6 +8,7 @@ import { toErrorText } from '../spec-common/errors'; import * as ptyType from 'node-pty'; import { Log, makeLog } from '../spec-utils/log'; import { Event } from '../spec-utils/event'; +import { escapeRegExCharacters } from '../spec-utils/strings'; export interface ContainerDetails { Id: string; @@ -351,7 +352,7 @@ function replacingDockerExecLog(original: Log, cmd: string, args: string[]) { } function replacingLog(original: Log, search: string, replace: string) { - const searchR = new RegExp(search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); + const searchR = new RegExp(escapeRegExCharacters(search), 'g'); const wrapped = makeLog({ ...original, get dimensions() { diff --git a/src/spec-utils/strings.ts b/src/spec-utils/strings.ts new file mode 100644 index 000000000..00a1352d4 --- /dev/null +++ b/src/spec-utils/strings.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +export function escapeRegExCharacters(str: string) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/test/container-features/generateFeaturesConfig.test.ts b/src/test/container-features/generateFeaturesConfig.test.ts index 480a17cb4..67b9754da 100644 --- a/src/test/container-features/generateFeaturesConfig.test.ts +++ b/src/test/container-features/generateFeaturesConfig.test.ts @@ -10,6 +10,7 @@ import { DevContainerConfig } from '../../spec-configuration/configuration'; import { URI } from 'vscode-uri'; import { getLocalCacheFolder } from '../../spec-node/utils'; import { shellExec } from '../testUtils'; +import { getEntPasswdShellCommand } from '../../spec-common/commonUtils'; export const output = makeLog(createPlainLog(text => process.stdout.write(text), () => LogLevel.Trace)); @@ -78,8 +79,8 @@ ENV MYKEYTWO="MY RESULT TWO"`; // getFeatureLayers const actualLayers = getFeatureLayers(featuresConfig, 'testContainerUser', 'testRemoteUser'); const expectedLayers = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \\ -echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env +echo "_CONTAINER_USER_HOME=$(${getEntPasswdShellCommand('testContainerUser')} | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \\ +echo "_REMOTE_USER_HOME=$(${getEntPasswdShellCommand('testRemoteUser')} | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/first_0 /tmp/dev-container-features/first_0 RUN chmod -R 0755 /tmp/dev-container-features/first_0 \\ @@ -140,8 +141,8 @@ RUN chmod -R 0755 /tmp/dev-container-features/second_1 \\ // getFeatureLayers const actualLayers = getFeatureLayers(featuresConfig, 'testContainerUser', 'testRemoteUser'); const expectedLayers = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd testContainerUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \\ -echo "_REMOTE_USER_HOME=$(getent passwd testRemoteUser | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env +echo "_CONTAINER_USER_HOME=$(${getEntPasswdShellCommand('testContainerUser')} | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env && \\ +echo "_REMOTE_USER_HOME=$(${getEntPasswdShellCommand('testRemoteUser')} | cut -d: -f6)" >> /tmp/dev-container-features/devcontainer-features.builtin.env COPY --chown=root:root --from=dev_containers_feature_content_source /tmp/build-features/color_0 /tmp/dev-container-features/color_0 diff --git a/src/test/dockerUtils.test.ts b/src/test/dockerUtils.test.ts index be2178e88..676a09f98 100644 --- a/src/test/dockerUtils.test.ts +++ b/src/test/dockerUtils.test.ts @@ -13,7 +13,7 @@ describe('Docker utils', function () { this.timeout(20 * 1000); it('inspect image in docker.io', async () => { - const imageName = 'docker.io/library/ubuntu:latest'; + const imageName = 'docker.io/library/centos:latest'; const config = await inspectImageInRegistry(output, { arch: 'x64', os: 'linux' }, imageName); assert.ok(config); assert.ok(config.Id); diff --git a/src/test/getEntPasswd.test.ts b/src/test/getEntPasswd.test.ts new file mode 100644 index 000000000..c5c851d84 --- /dev/null +++ b/src/test/getEntPasswd.test.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { shellExec, output } from './testUtils'; +import { dockerExecFunction } from '../spec-shutdown/dockerUtils'; +import { plainExec } from '../spec-common/commonUtils'; +import { launch } from '../spec-common/shellServer'; +import { getUserFromPasswdDB } from '../spec-common/injectHeadless'; + +describe('getEntPasswdShellCommand', function () { + this.timeout('20s'); + + [ + { + image: 'busybox', + getentPath: undefined, + addUserOptions: '-D -h', + userName: 'foo\\bar', + }, + { + image: 'debian', + getentPath: '/usr/bin/getent', + addUserOptions: '--disabled-password --allow-all-names --gecos "" --home', + userName: 'foo\\bar', + }, + { + image: 'alpine', + getentPath: '/usr/bin/getent', + addUserOptions: '-D -h', + userName: 'foo_bar', // Note: Alpine doesn't support backslash in user names. + }, + ].forEach(({ image, getentPath, addUserOptions, userName }) => { + it(`should work with ${image} ${getentPath ? 'with' : 'without'} getent command`, async () => { + const res = await shellExec(`docker run -d ${image} sleep inf`); + const containerId = res.stdout.trim(); + const exec = dockerExecFunction({ + exec: plainExec(undefined), + cmd: 'docker', + env: {}, + output, + }, containerId, 'root'); + const shellServer = await launch(exec, output); + + const which = await shellServer.exec('command -v getent') + .catch(() => undefined); + assert.strictEqual(which?.stdout.trim(), getentPath); + + await shellServer.exec(`adduser ${addUserOptions} /home/foo ${userName.replaceAll('\\', '\\\\')}`); + + const userByName = await getUserFromPasswdDB(shellServer, userName); + assert.ok(userByName); + assert.strictEqual(userByName.name, userName); + assert.strictEqual(userByName.home, '/home/foo'); + + const userById = await getUserFromPasswdDB(shellServer, userByName.uid); + assert.ok(userById); + assert.strictEqual(userById.name, userName); + assert.strictEqual(userById.home, '/home/foo'); + + await shellExec(`docker rm -f ${containerId}`); + }); + }); +});