From 115cca00d48937e761499d7775d8ea4c2c2f9247 Mon Sep 17 00:00:00 2001 From: Alexandros Karypidis <1221101+karypid@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:26:06 +0100 Subject: [PATCH 1/2] Fix podman keep-id mapping for numeric and named users Prefer parsing a numeric user spec directly from config and only fall back to resolving named users via a throwaway container, producing an explicit --userns=keep-id:uid=...,gid=... mapping. Add tests and config fixtures for numeric and named keep-id cases. --- CHANGELOG.md | 1 + src/spec-node/singleContainer.ts | 50 +++++++++++++++-- src/test/cli.podman.test.ts | 41 ++++++++++++++ .../podman-keep-id-numeric/.devcontainer.json | 7 +++ .../configs/podman-keep-id-numeric/Dockerfile | 4 ++ .../configs/podman-keep-id/.devcontainer.json | 7 +++ src/test/configs/podman-keep-id/Dockerfile | 4 ++ src/test/keepIdArgs.test.ts | 54 +++++++++++++++++++ 8 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 src/test/configs/podman-keep-id-numeric/.devcontainer.json create mode 100644 src/test/configs/podman-keep-id-numeric/Dockerfile create mode 100644 src/test/configs/podman-keep-id/.devcontainer.json create mode 100644 src/test/configs/podman-keep-id/Dockerfile create mode 100644 src/test/keepIdArgs.test.ts 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'] + ); + }); +}); From 202b2b9c23a5c3de1a2316dc759f7bf5dae96cf3 Mon Sep 17 00:00:00 2001 From: Alexandros Karypidis <1221101+karypid@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:14:13 +0100 Subject: [PATCH 2/2] fix: handle chown failure in updateRemoteUserUID --- src/spec-node/containerFeatures.ts | 56 ++++++++- src/test/updateRemoteUserUID.test.ts | 182 +++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/test/updateRemoteUserUID.test.ts diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index eebb793f2..b678c32f9 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import { DevContainerConfig } from '../spec-configuration/configuration'; import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils'; +import { CLIHost } from '../spec-common/cliHost'; 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'; @@ -422,7 +423,11 @@ export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParame const { common } = params; const { cliHost } = common; const { updateRemoteUserUID } = mergedConfig; - if (params.updateRemoteUserUIDDefault === 'never' || !(typeof updateRemoteUserUID === 'boolean' ? updateRemoteUserUID : params.updateRemoteUserUIDDefault === 'on') || !(cliHost.platform === 'linux' || params.updateRemoteUserUIDOnMacOS && cliHost.platform === 'darwin')) { + // Under rootless podman with a non-bakeable host UID/GID, fall back to disabling the + // build-time bake and relying on the runtime --userns=keep-id mapping instead. This + // only applies when the user has not explicitly configured updateRemoteUserUID. + const effectiveUpdateRemoteUserUID = await resolveUpdateRemoteUserUID(params, mergedConfig, cliHost) ?? updateRemoteUserUID; + if (params.updateRemoteUserUIDDefault === 'never' || !(typeof effectiveUpdateRemoteUserUID === 'boolean' ? effectiveUpdateRemoteUserUID : params.updateRemoteUserUIDDefault === 'on') || !(cliHost.platform === 'linux' || params.updateRemoteUserUIDOnMacOS && cliHost.platform === 'darwin')) { return null; } const details = await imageDetails(); @@ -442,6 +447,55 @@ export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParame }; } +// The default subuid/subgid range size that rootless podman grants to a user. A host +// UID/GID at or below this value can be baked into the image at build time; anything +// above it cannot be owned by the container under rootless podman. +const DEFAULT_SUBID_RANGE = 65536; + +// Returns true when the given host UID/GID can be baked into the image at build time +// under rootless podman. Rootless podman can only own files at UIDs within the user's +// subuid range (0..65536 by default). When the host UID is above that range, the +// build-time chown/usermod fails with EINVAL, so the CLI must fall back to the runtime +// --userns=keep-id mapping instead. +export function isBakeableUidGid(uid: number, gid: number): boolean { + return uid <= DEFAULT_SUBID_RANGE && gid <= DEFAULT_SUBID_RANGE; +} + +// Determines whether the CLI should fall back to updateRemoteUserUID: false under +// rootless podman. This only applies when the user has NOT explicitly configured +// updateRemoteUserUID (i.e. it is at its default), and the host UID/GID is not bakeable. +// A user-supplied setting is always respected. +export async function shouldFallbackToKeepId(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, cliHost: CLIHost): Promise { + if (params.cliVariant !== CLIVariant.Podman || cliHost.platform !== 'linux') { + return false; + } + // Never override a user-supplied setting. + if (typeof mergedConfig.updateRemoteUserUID === 'boolean') { + return false; + } + // Only fall back when the default would otherwise trigger the bake. + if (params.updateRemoteUserUIDDefault !== 'on') { + return false; + } + if (!cliHost.getuid || !cliHost.getgid) { + return false; + } + return !isBakeableUidGid(await cliHost.getuid(), await cliHost.getgid()); +} + +// Applies the rootless-podman fallback: when the host UID/GID is not bakeable and the +// user has not explicitly configured updateRemoteUserUID, disable the build-time bake +// and rely on the runtime --userns=keep-id mapping instead. Returns the effective +// updateRemoteUserUID value (a boolean when the fallback applies, otherwise undefined +// to keep the default behavior). +export async function resolveUpdateRemoteUserUID(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, cliHost: CLIHost): Promise { + if (await shouldFallbackToKeepId(params, mergedConfig, cliHost)) { + params.common.output.write('Host UID/GID is outside the rootless podman subuid range; disabling updateRemoteUserUID and relying on --userns=keep-id.', LogLevel.Warning); + return false; + } + return undefined; +} + export async function updateRemoteUserUID(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise, runArgsUser: string | undefined) { const { common } = params; const { cliHost } = common; diff --git a/src/test/updateRemoteUserUID.test.ts b/src/test/updateRemoteUserUID.test.ts new file mode 100644 index 000000000..0517fee62 --- /dev/null +++ b/src/test/updateRemoteUserUID.test.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { CLIVariant } from '../spec-shutdown/dockerUtils'; +import { CLIHost } from '../spec-common/cliHost'; +import { DockerResolverParameters } from '../spec-node/utils'; +import { isBakeableUidGid, shouldFallbackToKeepId, resolveUpdateRemoteUserUID } from '../spec-node/containerFeatures'; + +function makeCliHost(overrides: Partial = {}): CLIHost { + return { + type: 'local', + platform: 'linux', + arch: 'x64', + exec: async () => { throw new Error('not implemented'); }, + ptyExec: async () => { throw new Error('not implemented'); }, + cwd: '/', + env: {}, + path: require('path').posix, + homedir: async () => '/home/user', + tmpdir: async () => '/tmp', + isFile: async () => false, + isFolder: async () => false, + readFile: async () => Buffer.alloc(0), + writeFile: async () => { }, + rename: async () => { }, + mkdirp: async () => { }, + readDir: async () => [], + getUsername: async () => 'user', + getuid: async () => 1000, + getgid: async () => 1000, + toCommonURI: async () => undefined, + connect: () => { throw new Error('not implemented'); }, + ...overrides, + }; +} + +function makeParams(overrides: Partial = {}): DockerResolverParameters { + return { + common: { + prebuild: false, + computeExtensionHostEnv: false, + package: { version: '0.0.0' } as any, + containerDataFolder: undefined, + containerSystemDataFolder: undefined, + appRoot: undefined, + extensionPath: '/ext', + sessionId: 'session', + sessionStart: new Date(), + cliHost: makeCliHost(), + env: {}, + cwd: '/', + isLocalContainer: true, + dotfilesConfiguration: {} as any, + progress: () => { }, + output: { write: () => { }, raw: () => { }, start: () => 0, stop: () => { }, event: () => { } } as any, + allowSystemConfigChange: false, + defaultUserEnvProbe: {} as any, + lifecycleHook: {} as any, + getLogLevel: () => 0 as any, + onDidChangeLogLevel: () => () => { }, + loadNativeModule: async () => undefined, + allowInheritTTY: false, + shutdowns: [], + backgroundTasks: [], + persistedFolder: '/tmp', + remoteEnv: {}, + } as any, + parsedAuthority: undefined, + dockerCLI: 'docker', + cliVariant: CLIVariant.Podman, + dockerComposeCLI: async () => { throw new Error('not implemented'); }, + dockerEnv: {}, + workspaceMountConsistencyDefault: 'cached', + gpuAvailability: 'detect', + mountWorkspaceGitRoot: false, + mountGitWorktreeCommonDir: false, + updateRemoteUserUIDOnMacOS: false, + cacheMount: 'bind', + userRepositoryConfigurationPaths: [], + additionalMounts: [], + updateRemoteUserUIDDefault: 'on', + additionalCacheFroms: [], + buildKitVersion: undefined, + dockerEngineVersion: undefined, + buildxPlatform: undefined, + buildxPush: false, + additionalLabels: [], + buildxOutput: undefined, + buildxCacheTo: undefined, + buildPlatformInfo: {} as any, + targetPlatformInfo: {} as any, + isTTY: false, + ...overrides, + }; +} + +describe('isBakeableUidGid', function () { + it('should return true for uid/gid within the subuid range', () => { + assert.strictEqual(isBakeableUidGid(1000, 1000), true); + assert.strictEqual(isBakeableUidGid(65536, 65536), true); + }); + + it('should return false when the uid exceeds the subuid range', () => { + assert.strictEqual(isBakeableUidGid(1400601154, 1000), false); + }); + + it('should return false when the gid exceeds the subuid range', () => { + assert.strictEqual(isBakeableUidGid(1000, 1400601513), false); + }); + + it('should return false when both exceed the subuid range', () => { + assert.strictEqual(isBakeableUidGid(1400601154, 1400601513), false); + }); +}); + +describe('shouldFallbackToKeepId', function () { + it('should fall back for rootless podman with a non-bakeable host uid', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await shouldFallbackToKeepId(params, {} as any, params.common.cliHost), true); + }); + + it('should not fall back when the host uid is bakeable', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: async () => 1000, getgid: async () => 1000 }); + assert.strictEqual(await shouldFallbackToKeepId(params, {} as any, params.common.cliHost), false); + }); + + it('should not fall back for docker (non-podman)', async () => { + const params = makeParams({ cliVariant: CLIVariant.Docker }); + params.common.cliHost = makeCliHost({ getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await shouldFallbackToKeepId(params, {} as any, params.common.cliHost), false); + }); + + it('should not fall back on a non-linux platform', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ platform: 'darwin', getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await shouldFallbackToKeepId(params, {} as any, params.common.cliHost), false); + }); + + it('should not fall back when the user explicitly set updateRemoteUserUID', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await shouldFallbackToKeepId(params, { updateRemoteUserUID: true } as any, params.common.cliHost), false); + assert.strictEqual(await shouldFallbackToKeepId(params, { updateRemoteUserUID: false } as any, params.common.cliHost), false); + }); + + it('should not fall back when the default is not "on"', async () => { + const params = makeParams({ updateRemoteUserUIDDefault: 'never' }); + params.common.cliHost = makeCliHost({ getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await shouldFallbackToKeepId(params, {} as any, params.common.cliHost), false); + }); + + it('should not fall back when getuid/getgid are unavailable', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: undefined, getgid: undefined }); + assert.strictEqual(await shouldFallbackToKeepId(params, {} as any, params.common.cliHost), false); + }); +}); + +describe('resolveUpdateRemoteUserUID', function () { + it('should return false (disable bake) when the fallback applies', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await resolveUpdateRemoteUserUID(params, {} as any, params.common.cliHost), false); + }); + + it('should return undefined when the host uid is bakeable', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: async () => 1000, getgid: async () => 1000 }); + assert.strictEqual(await resolveUpdateRemoteUserUID(params, {} as any, params.common.cliHost), undefined); + }); + + it('should return undefined when the user explicitly configured updateRemoteUserUID', async () => { + const params = makeParams(); + params.common.cliHost = makeCliHost({ getuid: async () => 1400601154, getgid: async () => 1400601513 }); + assert.strictEqual(await resolveUpdateRemoteUserUID(params, { updateRemoteUserUID: true } as any, params.common.cliHost), undefined); + }); +});