From 6ec5c6b286b66cf79f015cd3276f2eab462ba1b9 Mon Sep 17 00:00:00 2001 From: Priya Gupta <147705955+v-priyagupta108@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:43:43 +0530 Subject: [PATCH] Add fallback install directory when default is not writable (#785) * Add fallback install directory when default is not writable * Refactor macOS install directory handling to warn when default is not writable --- README.md | 8 +- __tests__/installer.test.ts | 276 ++++++++++++++++++++++++++++++++++-- dist/setup/index.js | 86 ++++++++++- src/installer.ts | 104 ++++++++++++-- 4 files changed, 449 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 68ae8ac..d9390c0 100644 --- a/README.md +++ b/README.md @@ -452,7 +452,13 @@ You can also set `DOTNET_INSTALL_DIR` to a value based on runtime variables, suc - name: Set DOTNET_INSTALL_DIR run: echo "DOTNET_INSTALL_DIR=$HOME/.dotnet" >> $GITHUB_ENV ``` -> **Note**: On some self-hosted or large Linux runners, installing .NET under the default `/usr/share/dotnet` location may fail due to insufficient permissions. To ensure successful installation, set `DOTNET_INSTALL_DIR` to a user-writable path. +> **Note**: On some self-hosted, larger or containerized runners the default location is not writable by the current user, and the installation fails with a permission error. The action resolves the install directory in this order: +> +> 1. `DOTNET_INSTALL_DIR`, when set. It is always honored and never probed. +> 2. The default location for the runner OS, when it is writable. +> 3. `.dotnet` in the current user's home directory, for example `/home/runner/.dotnet` or `C:\Users\runneradmin\.dotnet`. +> +> Runners that can write to the default location are unaffected. When the fallback is used, `DOTNET_ROOT` and `PATH` point at the home directory, so .NET preinstalled under the system location is no longer picked up. Set `DOTNET_INSTALL_DIR` explicitly to pin the location. If no writable location is found, the action warns and keeps the default, so the installation is likely to fail. ## Recommended permissions diff --git a/__tests__/installer.test.ts b/__tests__/installer.test.ts index 7666064..e14bfb3 100644 --- a/__tests__/installer.test.ts +++ b/__tests__/installer.test.ts @@ -11,6 +11,7 @@ import { import each from 'jest-each'; import semver from 'semver'; import fspromises from 'fs/promises'; +import type {Stats} from 'fs'; import os from 'os'; import path from 'path'; @@ -35,26 +36,47 @@ jest.unstable_mockModule('@actions/io', () => ({ jest.unstable_mockModule('fs', () => { const actual = jest.requireActual('fs') as typeof import('fs'); const chmodSync = jest.fn(); + const lstatSync = jest.fn(actual.lstatSync); + const mkdtempSync = jest.fn(actual.mkdtempSync); + const rmSync = jest.fn(actual.rmSync); const readdirSync = jest.fn(); const existsSync = jest.fn(() => true); const statSync = jest.fn(() => ({isFile: () => true})); const accessSync = jest.fn(); - return { + const mocked = { ...actual, chmodSync, + lstatSync, + mkdtempSync, + rmSync, readdirSync, existsSync, statSync, - accessSync, - default: { - ...actual, - chmodSync, - readdirSync, - existsSync, - statSync, - accessSync - } + accessSync }; + return { + ...mocked, + default: mocked + }; +}); + +jest.unstable_mockModule('os', () => { + const actual = jest.requireActual('os') as typeof import('os'); + // os.homedir() reads the real environment, not jest's process.env. + const homedir = jest.fn(actual.homedir); + // Resolved per call, so spies placed on the real module still take effect. + const arch = () => actual.arch(); + return {...actual, homedir, arch, default: {...actual, homedir, arch}}; +}); + +// Allows tests to re-import the installer as if running on another OS. +let platformOverride: 'windows' | 'linux' | 'mac' | undefined; +jest.unstable_mockModule('../src/utils.js', () => { + const actual = jest.requireActual( + '../src/utils.js' + ) as typeof import('../src/utils.js'); + const platform = platformOverride ?? actual.PLATFORM; + return {...actual, PLATFORM: platform, IS_WINDOWS: platform === 'windows'}; }); const exec = await import('@actions/exec'); @@ -1364,6 +1386,240 @@ describe('installer tests', () => { expect(path).toContain(process.env['DOTNET_INSTALL_DIR']); }); }); + + describe('install directory fallback tests', () => { + // Mock factories re-run on resetModules, so the installer sees new + // instances; configure those, not the ones captured at file load. + const importInstallerFor = async ( + platform: 'windows' | 'linux' | 'mac', + isWritable: (dir: string) => boolean + ) => { + platformOverride = platform; + jest.resetModules(); + + const freshFs = await import('fs'); + (freshFs.lstatSync as jest.Mock).mockReturnValue({} as Stats); + (freshFs.mkdtempSync as jest.Mock).mockImplementation( + (...args: unknown[]) => { + const prefix = String(args[0]); + if (!isWritable(path.dirname(prefix))) { + throw Object.assign(new Error('permission denied'), { + code: 'EACCES' + }); + } + return `${prefix}abc123`; + } + ); + (freshFs.rmSync as jest.Mock).mockImplementation(() => {}); + + const freshCore = await import('@actions/core'); + const freshOs = await import('os'); + const {DotnetInstallDir} = await import('../src/installer.js'); + + return {DotnetInstallDir, core: freshCore, fs: freshFs, os: freshOs}; + }; + + const probedDirs = (freshFs: typeof import('fs')) => + (freshFs.mkdtempSync as jest.Mock).mock.calls.map(call => + path.dirname(String(call[0])) + ); + + beforeEach(() => { + delete process.env['DOTNET_INSTALL_DIR']; + }); + + afterEach(() => { + platformOverride = undefined; + }); + + it(`should use the default location when it is writable`, async () => { + const {DotnetInstallDir} = await importInstallerFor( + 'linux', + () => true + ); + + expect(DotnetInstallDir.dirPath).toBe('/usr/share/dotnet'); + }); + + it(`should fall back to the home directory when the default location is not writable`, async () => { + const fallbackPath = path.join(os.homedir(), '.dotnet'); + const {DotnetInstallDir, core: freshCore} = await importInstallerFor( + 'linux', + dir => dir === fallbackPath + ); + + expect(DotnetInstallDir.dirPath).toBe(fallbackPath); + expect(freshCore.warning).toHaveBeenCalled(); + }); + + it(`should reject a dangling symlink instead of probing its writable parent`, async () => { + // Resolved, because that is the form the probe receives on every platform. + const linkPath = path.resolve('/usr/share/dotnet'); + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'linux', + () => true + ); + // A dangling link exists for lstat, but nothing can be created under it. + (freshFs.mkdtempSync as jest.Mock).mockImplementation( + (...args: unknown[]) => { + if (path.dirname(String(args[0])) === linkPath) { + throw Object.assign(new Error('no such file or directory'), { + code: 'ENOENT' + }); + } + return `${args[0]}abc123`; + } + ); + + expect(DotnetInstallDir.dirPath).toBe( + path.join(os.homedir(), '.dotnet') + ); + const probed = probedDirs(freshFs); + expect(probed).toContain(linkPath); + expect(probed).not.toContain(path.dirname(linkPath)); + }); + + it(`should probe the nearest existing parent when the directory is missing`, async () => { + const systemPath = path.resolve('/usr/share/dotnet'); + const parentPath = path.dirname(systemPath); + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'linux', + () => true + ); + // Only the parent exists, so the walk has to climb before probing. + (freshFs.lstatSync as jest.Mock).mockImplementation( + (...args: unknown[]) => + path.resolve(String(args[0])) === parentPath + ? ({} as Stats) + : undefined + ); + + expect(DotnetInstallDir.dirPath).toBe('/usr/share/dotnet'); + const probed = probedDirs(freshFs); + expect(probed).toContain(parentPath); + expect(probed).not.toContain(systemPath); + }); + + it(`should give up at the filesystem root when no ancestor exists`, async () => { + const systemPath = path.resolve('/usr/share/dotnet'); + const homePath = path.join(os.homedir(), '.dotnet'); + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'linux', + () => true + ); + // Nothing on the system path exists, all the way up to the root. + (freshFs.lstatSync as jest.Mock).mockImplementation( + (...args: unknown[]) => + systemPath.startsWith(path.resolve(String(args[0]))) + ? undefined + : ({} as Stats) + ); + + expect(DotnetInstallDir.dirPath).toBe(homePath); + // The walk hit the root and returned without ever probing. + expect(probedDirs(freshFs)).toEqual([homePath]); + }); + + it(`should not probe a relative default location`, async () => { + // An unset HOME makes the macOS default relative, e.g. 'undefined/.dotnet'. + delete process.env['HOME']; + const homePath = path.join(os.homedir(), '.dotnet'); + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'mac', + () => true + ); + + expect(DotnetInstallDir.dirPath).toBe(homePath); + expect(probedDirs(freshFs)).not.toContain(process.cwd()); + }); + + it(`should not probe a relative default location on Windows`, async () => { + // An unset PROGRAMFILES makes the Windows default relative. + delete process.env['PROGRAMFILES']; + const homePath = path.join(os.homedir(), '.dotnet'); + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'windows', + () => true + ); + + expect(DotnetInstallDir.dirPath).toBe(homePath); + expect(probedDirs(freshFs)).not.toContain(process.cwd()); + }); + + it(`should not treat the filesystem root as a home directory`, async () => { + const root = path.parse(process.cwd()).root; + const rootDotnet = path.join(root, '.dotnet'); + // Only the root candidate is writable, so an unguarded run would pick it. + const { + DotnetInstallDir, + os: freshOs, + core: freshCore + } = await importInstallerFor('linux', dir => dir === rootDotnet); + (freshOs.homedir as jest.Mock).mockReturnValue(root); + + expect(DotnetInstallDir.dirPath).toBe('/usr/share/dotnet'); + expect(freshCore.warning).toHaveBeenCalled(); + }); + + it(`should still report writable when the probe cannot be removed`, async () => { + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'linux', + () => true + ); + (freshFs.rmSync as jest.Mock).mockImplementation(() => { + throw Object.assign(new Error('device or resource busy'), { + code: 'EBUSY' + }); + }); + + expect(DotnetInstallDir.dirPath).toBe('/usr/share/dotnet'); + }); + + it(`should keep the shared macOS location and warn when it is not writable`, async () => { + // The mac default derives from HOME, homeInstallPath() from os.homedir(); + // aligning them is what makes both candidates the same path. + process.env['HOME'] = os.homedir(); + const homePath = path.join(os.homedir(), '.dotnet'); + const { + DotnetInstallDir, + fs: freshFs, + core: freshCore + } = await importInstallerFor('mac', () => false); + + expect(DotnetInstallDir.dirPath).toBe(homePath); + // One candidate, so it is probed once rather than twice. + expect(probedDirs(freshFs)).toEqual([homePath]); + expect(freshCore.warning).toHaveBeenCalled(); + }); + + it(`should prefer DOTNET_INSTALL_DIR env.var without probing`, async () => { + process.env['DOTNET_INSTALL_DIR'] = path.join(path.sep, 'custom'); + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'linux', + () => false + ); + + expect(DotnetInstallDir.dirPath).toBe(path.join(path.sep, 'custom')); + expect(freshFs.mkdtempSync as jest.Mock).not.toHaveBeenCalled(); + }); + + it(`should resolve once, and not before the directory is needed`, async () => { + const {DotnetInstallDir, fs: freshFs} = await importInstallerFor( + 'linux', + () => true + ); + const probe = freshFs.mkdtempSync as jest.Mock; + + expect(probe).not.toHaveBeenCalled(); + + void DotnetInstallDir.dirPath; + const afterFirstRead = probe.mock.calls.length; + void DotnetInstallDir.dirPath; + + expect(afterFirstRead).toBeGreaterThan(0); + expect(probe.mock.calls.length).toBe(afterFirstRead); + }); + }); }); describe('normalizeArch() tests', () => { diff --git a/dist/setup/index.js b/dist/setup/index.js index c3c8051..3c5d5ed 100644 --- a/dist/setup/index.js +++ b/dist/setup/index.js @@ -45748,9 +45748,84 @@ class DotnetInstallDir { mac: external_path_default().join(process.env['HOME'] + '', '.dotnet'), windows: external_path_default().join(process.env['PROGRAMFILES'] + '', 'dotnet') }; - static dirPath = process.env['DOTNET_INSTALL_DIR'] - ? DotnetInstallDir.convertInstallPathToAbsolute(process.env['DOTNET_INSTALL_DIR']) - : DotnetInstallDir.default[PLATFORM]; + static resolvedDirPath; + // Resolved on first use so a job that installs nothing never touches the disk. + static get dirPath() { + DotnetInstallDir.resolvedDirPath ??= DotnetInstallDir.resolveDirPath(); + return DotnetInstallDir.resolvedDirPath; + } + static resolveDirPath() { + if (process.env['DOTNET_INSTALL_DIR']) { + return DotnetInstallDir.convertInstallPathToAbsolute(process.env['DOTNET_INSTALL_DIR']); + } + const systemPath = DotnetInstallDir.default[PLATFORM]; + const homePath = DotnetInstallDir.homeInstallPath(); + // A relative default (unset HOME/PROGRAMFILES) would probe the cwd. + if (external_path_default().isAbsolute(systemPath) && + DotnetInstallDir.isWritableLocation(systemPath)) { + return systemPath; + } + // On macOS both candidates are the same path, already probed above. + const hasFallback = !!homePath && homePath !== systemPath; + if (hasFallback && DotnetInstallDir.isWritableLocation(homePath)) { + warning(`The default .NET install directory '${systemPath}' is not writable by the current user. Falling back to '${homePath}'; .NET preinstalled in the default location will no longer be used. Set the DOTNET_INSTALL_DIR environment variable to override this location.`); + return homePath; + } + // Not setFailed: the install may still succeed, so let it report the error. + warning(hasFallback + ? `Neither the default .NET install directory '${systemPath}' nor '${homePath}' is writable by the current user. Keeping '${systemPath}', but the installation is likely to fail. Set the DOTNET_INSTALL_DIR environment variable to a writable location.` + : `The default .NET install directory '${systemPath}' is not writable by the current user. Keeping it, but the installation is likely to fail. Set the DOTNET_INSTALL_DIR environment variable to a writable location.`); + return systemPath; + } + static homeInstallPath() { + try { + const home = external_os_default().homedir(); + // An empty HOME resolves against the cwd; a root HOME gives '/.dotnet'. + if (!external_path_default().isAbsolute(home) || home === external_path_default().parse(home).root) { + return undefined; + } + return external_path_default().join(home, '.dotnet'); + } + catch { + return undefined; + } + } + // Writability is tested by creating a directory. accessSync is not enough: it + // ignores Windows ACLs, so it reports success where the install would fail. + static isWritableLocation(installDir) { + let existingPath = external_path_default().resolve(installDir); + try { + // lstat also matches a broken symlink. existsSync does not, and the walk + // would skip past it to a writable parent and wrongly report success. + while (!(0,external_fs_namespaceObject.lstatSync)(existingPath, { throwIfNoEntry: false })) { + const parentPath = external_path_default().dirname(existingPath); + if (parentPath === existingPath) + return false; + existingPath = parentPath; + } + } + catch { + return false; + } + let probeDir; + try { + probeDir = (0,external_fs_namespaceObject.mkdtempSync)(external_path_default().join(existingPath, '.setup-dotnet-probe-')); + return true; + } + catch { + return false; + } + finally { + if (probeDir) { + try { + (0,external_fs_namespaceObject.rmSync)(probeDir, { recursive: true, force: true }); + } + catch { + // Throwing here would discard the result already returned above. + } + } + } + } static convertInstallPathToAbsolute(installDir) { if (external_path_default().isAbsolute(installDir)) return external_path_default().normalize(installDir); @@ -45810,9 +45885,6 @@ class DotnetCoreInstaller { rollForward; skipLtsPrepass; static FeatureBandSyntax = /^(\d+)\.(\d+)\.(\d)xx$/; - static { - DotnetInstallDir.setEnvironmentVariable(); - } constructor(version, quality, architecture, dotnetChannel, checkLatest = true, minimumVersion, rollForward, skipLtsPrepass = false) { this.version = version; this.quality = quality; @@ -46000,6 +46072,8 @@ class DotnetCoreInstaller { return null; } async installDotnet() { + // Before findLocalSdkVersion(), which looks under the resolved directory. + DotnetInstallDir.setEnvironmentVariable(); const isCrossArch = !!this.architecture && normalizeArch(this.architecture) !== normalizeArch(external_os_default().arch()); if (!this.checkLatest && !isCrossArch) { diff --git a/src/installer.ts b/src/installer.ts index 60291c1..9015cd0 100644 --- a/src/installer.ts +++ b/src/installer.ts @@ -7,7 +7,10 @@ import { accessSync, chmodSync, constants as fsConstants, + lstatSync, + mkdtempSync, readdirSync, + rmSync, statSync } from 'fs'; import path from 'path'; @@ -347,11 +350,97 @@ export abstract class DotnetInstallDir { windows: path.join(process.env['PROGRAMFILES'] + '', 'dotnet') }; - public static readonly dirPath = process.env['DOTNET_INSTALL_DIR'] - ? DotnetInstallDir.convertInstallPathToAbsolute( + private static resolvedDirPath: string | undefined; + + // Resolved on first use so a job that installs nothing never touches the disk. + public static get dirPath(): string { + DotnetInstallDir.resolvedDirPath ??= DotnetInstallDir.resolveDirPath(); + return DotnetInstallDir.resolvedDirPath; + } + + private static resolveDirPath(): string { + if (process.env['DOTNET_INSTALL_DIR']) { + return DotnetInstallDir.convertInstallPathToAbsolute( process.env['DOTNET_INSTALL_DIR'] - ) - : DotnetInstallDir.default[PLATFORM]; + ); + } + + const systemPath = DotnetInstallDir.default[PLATFORM]; + const homePath = DotnetInstallDir.homeInstallPath(); + + // A relative default (unset HOME/PROGRAMFILES) would probe the cwd. + if ( + path.isAbsolute(systemPath) && + DotnetInstallDir.isWritableLocation(systemPath) + ) { + return systemPath; + } + + // On macOS both candidates are the same path, already probed above. + const hasFallback = !!homePath && homePath !== systemPath; + + if (hasFallback && DotnetInstallDir.isWritableLocation(homePath!)) { + core.warning( + `The default .NET install directory '${systemPath}' is not writable by the current user. Falling back to '${homePath}'; .NET preinstalled in the default location will no longer be used. Set the DOTNET_INSTALL_DIR environment variable to override this location.` + ); + return homePath!; + } + + // Not setFailed: the install may still succeed, so let it report the error. + core.warning( + hasFallback + ? `Neither the default .NET install directory '${systemPath}' nor '${homePath}' is writable by the current user. Keeping '${systemPath}', but the installation is likely to fail. Set the DOTNET_INSTALL_DIR environment variable to a writable location.` + : `The default .NET install directory '${systemPath}' is not writable by the current user. Keeping it, but the installation is likely to fail. Set the DOTNET_INSTALL_DIR environment variable to a writable location.` + ); + return systemPath; + } + + private static homeInstallPath(): string | undefined { + try { + const home = os.homedir(); + // An empty HOME resolves against the cwd; a root HOME gives '/.dotnet'. + if (!path.isAbsolute(home) || home === path.parse(home).root) { + return undefined; + } + return path.join(home, '.dotnet'); + } catch { + return undefined; + } + } + + // Writability is tested by creating a directory. accessSync is not enough: it + // ignores Windows ACLs, so it reports success where the install would fail. + private static isWritableLocation(installDir: string): boolean { + let existingPath = path.resolve(installDir); + + try { + // lstat also matches a broken symlink. existsSync does not, and the walk + // would skip past it to a writable parent and wrongly report success. + while (!lstatSync(existingPath, {throwIfNoEntry: false})) { + const parentPath = path.dirname(existingPath); + if (parentPath === existingPath) return false; + existingPath = parentPath; + } + } catch { + return false; + } + + let probeDir: string | undefined; + try { + probeDir = mkdtempSync(path.join(existingPath, '.setup-dotnet-probe-')); + return true; + } catch { + return false; + } finally { + if (probeDir) { + try { + rmSync(probeDir, {recursive: true, force: true}); + } catch { + // Throwing here would discard the result already returned above. + } + } + } + } private static convertInstallPathToAbsolute(installDir: string): string { if (path.isAbsolute(installDir)) return path.normalize(installDir); @@ -410,10 +499,6 @@ function isExecutableFile(filePath: string): boolean { export class DotnetCoreInstaller { private static readonly FeatureBandSyntax = /^(\d+)\.(\d+)\.(\d)xx$/; - static { - DotnetInstallDir.setEnvironmentVariable(); - } - constructor( private version: string, private quality: QualityOptions, @@ -687,6 +772,9 @@ export class DotnetCoreInstaller { } public async installDotnet(): Promise { + // Before findLocalSdkVersion(), which looks under the resolved directory. + DotnetInstallDir.setEnvironmentVariable(); + const isCrossArch = !!this.architecture && normalizeArch(this.architecture) !== normalizeArch(os.arch());