From 928037783a71f24983ea250c6e55290c1b2de54f Mon Sep 17 00:00:00 2001 From: Sascha Bratton Date: Thu, 2 Jul 2026 13:41:07 -0400 Subject: [PATCH] fix: work around git dubious ownership errors in container jobs (#317) --- .../workflows/pull-request-verification.yml | 57 ++++ README.md | 5 + __tests__/git-exec.test.ts | 113 ++++++++ __tests__/safe-directory.test.ts | 256 ++++++++++++++++++ dist/index.js | 230 ++++++++++++++-- src/git.ts | 74 +++-- src/main.ts | 3 + src/safe-directory.ts | 135 +++++++++ 8 files changed, 833 insertions(+), 40 deletions(-) create mode 100644 __tests__/git-exec.test.ts create mode 100644 __tests__/safe-directory.test.ts create mode 100644 src/safe-directory.ts diff --git a/.github/workflows/pull-request-verification.yml b/.github/workflows/pull-request-verification.yml index 45f4dcb..00899eb 100644 --- a/.github/workflows/pull-request-verification.yml +++ b/.github/workflows/pull-request-verification.yml @@ -74,6 +74,63 @@ jobs: if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true' run: exit 1 + test-container-without-token: + runs-on: ubuntu-latest + strategy: + matrix: + # bullseye: git 2.30 + Debian CVE-2022-24765 backport, old "unsafe repository" wording, + # pre-2.32 -> ignores GIT_CONFIG_GLOBAL -> exercises the HOME-only path + # bookworm: git 2.39, "dubious ownership" wording, honors GIT_CONFIG_GLOBAL + container: ['node:24-bullseye', 'node:24-bookworm'] + locale: [''] + include: + # zh_CN: git translates the dubious-ownership message via gettext - proves + # detection works on non-English stderr regardless of the container's locale. + # A CJK locale is the most adversarial probe (multibyte, non-Latin) whose + # catalog actually translates this message (ja does not exist, ko lacks it) + - container: 'node:24-bookworm' + locale: 'zh_CN.UTF-8' + container: ${{ matrix.container }} + steps: + - uses: actions/checkout@v6 + - name: Generate locale + if: matrix.locale != '' + run: | + apt-get update + apt-get install -y locales + echo '${{ matrix.locale }} UTF-8' >> /etc/locale.gen + locale-gen + - name: Verify dubious ownership is reproduced + run: | + if git status; then + echo "::error::git succeeded - environment no longer reproduces dubious ownership" + exit 1 + fi + - name: Verify git message is localized + if: matrix.locale != '' + env: + LC_ALL: ${{ matrix.locale }} + run: | + if stderr=$(git status 2>&1 >/dev/null); then + echo "::error::git succeeded - environment no longer reproduces dubious ownership" + exit 1 + fi + echo "$stderr" + if echo "$stderr" | grep -qE 'dubious ownership|unsafe repository'; then + echo "::error::git message is not translated - the locale variant would not test anything" + exit 1 + fi + - uses: ./ + id: filter + env: + LC_ALL: ${{ matrix.locale }} + with: + token: '' + filters: '.github/filters.yml' + - name: filter-test + if: steps.filter.outputs.any != 'true' || steps.filter.outputs.error == 'true' + run: exit 1 + test-wd-without-token: runs-on: ubuntu-latest steps: diff --git a/README.md b/README.md index a4b1035..c94571d 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,14 @@ For more scenarios see [examples](#examples) section. - It's recommended to quote your path expressions with `'` or `"`. Otherwise, you will get an error if it starts with `*`. - Local execution with [act](https://github.com/nektos/act) works only with alternative runner image. Default runner doesn't have `git` binary. - Use: `act -P ubuntu-latest=nektos/act-environments-ubuntu:18.04` +- Git `dubious ownership` errors in [container jobs](https://docs.github.com/en/actions/using-containerized-services/running-jobs-in-a-container) are handled automatically - + the action retries with a temporary `HOME` containing a `safe.directory` entry, the same technique used by [actions/checkout](https://github.com/actions/checkout). + Only if fetching relies on credentials stored in `HOME`-relative files (e.g. `~/.git-credentials` or `~/.netrc`), + mark the repository as safe yourself in a step before this action: `git config --global --add safe.directory "$GITHUB_WORKSPACE"` ## What's New +- Automatic workaround for git `dubious ownership` errors in container jobs - New major release `v4` after update to Node 24 [Breaking change] - Add `ref` input parameter - Add `list-files: csv` format diff --git a/__tests__/git-exec.test.ts b/__tests__/git-exec.test.ts new file mode 100644 index 0000000..37f20dd --- /dev/null +++ b/__tests__/git-exec.test.ts @@ -0,0 +1,113 @@ +import {getExecOutput, ExecOutput} from '@actions/exec' +import {gitExec} from '../src/git' +import {ensureSafeDirectory, getGitEnv} from '../src/safe-directory' + +jest.mock('@actions/exec') +jest.mock('../src/safe-directory', () => ({ + ...jest.requireActual('../src/safe-directory'), + ensureSafeDirectory: jest.fn(), + getGitEnv: jest.fn() +})) + +const getExecOutputMock = getExecOutput as jest.MockedFunction +const ensureSafeDirectoryMock = ensureSafeDirectory as jest.MockedFunction +const getGitEnvMock = getGitEnv as jest.MockedFunction + +const SUCCESS_OUTPUT: ExecOutput = {exitCode: 0, stdout: 'ok', stderr: ''} +const DUBIOUS_OUTPUT: ExecOutput = { + exitCode: 128, + stdout: '', + stderr: "fatal: detected dubious ownership in repository at '/github/workspace'" +} + +// clearMocks in jest.config.js does not remove queued mockResolvedValueOnce values or implementations +beforeEach(() => { + getExecOutputMock.mockReset() + ensureSafeDirectoryMock.mockReset() + getGitEnvMock.mockReset() +}) + +describe('gitExec', () => { + test('returns result of successful command without invoking the workaround', async () => { + getExecOutputMock.mockResolvedValueOnce(SUCCESS_OUTPUT) + + const result = await gitExec(['status']) + + expect(result).toBe(SUCCESS_OUTPUT) + expect(getExecOutputMock).toHaveBeenCalledTimes(1) + expect(getExecOutputMock).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({ignoreReturnCode: true})) + expect(ensureSafeDirectoryMock).not.toHaveBeenCalled() + }) + + test('passes environment from getGitEnv to git', async () => { + const env = {HOME: '/temp/home'} + getGitEnvMock.mockReturnValue(env) + getExecOutputMock.mockResolvedValueOnce(SUCCESS_OUTPUT) + + await gitExec(['status']) + + expect(getExecOutputMock).toHaveBeenCalledWith('git', ['status'], expect.objectContaining({env})) + }) + + test('retries once after dubious ownership error is worked around', async () => { + getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(SUCCESS_OUTPUT) + ensureSafeDirectoryMock.mockResolvedValueOnce(true) + + const result = await gitExec(['status']) + + expect(result).toBe(SUCCESS_OUTPUT) + expect(ensureSafeDirectoryMock).toHaveBeenCalledWith(DUBIOUS_OUTPUT.stderr) + expect(getExecOutputMock).toHaveBeenCalledTimes(2) + for (const call of getExecOutputMock.mock.calls) { + expect(call[2]).toEqual(expect.objectContaining({ignoreReturnCode: true})) + } + }) + + test('throws actionable error when retry still fails with dubious ownership', async () => { + getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(DUBIOUS_OUTPUT) + ensureSafeDirectoryMock.mockResolvedValueOnce(true) + + const promise = gitExec(['status']) + + await expect(promise).rejects.toThrow(/detected dubious ownership/) + await expect(promise).rejects.toThrow(/safe\.directory/) + await expect(promise).rejects.toThrow(/--user/) + expect(getExecOutputMock).toHaveBeenCalledTimes(2) + }) + + test('throws without retry when workaround adds nothing new', async () => { + getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT) + ensureSafeDirectoryMock.mockResolvedValueOnce(false) + + await expect(gitExec(['status'])).rejects.toThrow(/safe\.directory/) + expect(getExecOutputMock).toHaveBeenCalledTimes(1) + }) + + test('returns non-dubious failure when ignoreReturnCode is set', async () => { + const failure: ExecOutput = {exitCode: 1, stdout: '', stderr: 'some error'} + getExecOutputMock.mockResolvedValueOnce(failure) + + const result = await gitExec(['show-ref', 'master'], {ignoreReturnCode: true}) + + expect(result).toBe(failure) + expect(ensureSafeDirectoryMock).not.toHaveBeenCalled() + }) + + test('retries dubious ownership error even when ignoreReturnCode is set', async () => { + getExecOutputMock.mockResolvedValueOnce(DUBIOUS_OUTPUT).mockResolvedValueOnce(SUCCESS_OUTPUT) + ensureSafeDirectoryMock.mockResolvedValueOnce(true) + + const result = await gitExec(['show-ref', 'master'], {ignoreReturnCode: true}) + + expect(result).toBe(SUCCESS_OUTPUT) + expect(getExecOutputMock).toHaveBeenCalledTimes(2) + }) + + test('throws on non-dubious failure when ignoreReturnCode is not set', async () => { + getExecOutputMock.mockResolvedValueOnce({exitCode: 1, stdout: '', stderr: 'some error'}) + + await expect(gitExec(['fetch'])).rejects.toThrow("The process 'git fetch' failed with exit code 1") + expect(getExecOutputMock).toHaveBeenCalledTimes(1) + expect(ensureSafeDirectoryMock).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/safe-directory.test.ts b/__tests__/safe-directory.test.ts new file mode 100644 index 0000000..caf5bf8 --- /dev/null +++ b/__tests__/safe-directory.test.ts @@ -0,0 +1,256 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import {exec} from '@actions/exec' +import { + buildGitEnv, + cleanup, + createTempGitHome, + ensureSafeDirectory, + getGitEnv, + isDubiousOwnershipError, + parseRepositoryPath, + resolveTempBaseDir +} from '../src/safe-directory' + +jest.mock('@actions/exec') + +const execMock = exec as jest.MockedFunction + +const DUBIOUS_STDERR = "fatal: detected dubious ownership in repository at '/github/workspace'" +const UNSAFE_STDERR = "fatal: unsafe repository ('/github/workspace' is owned by someone else)" + +describe('detection of dubious ownership errors', () => { + test('detects "detected dubious ownership" wording at exit code 128', () => { + expect(isDubiousOwnershipError(128, DUBIOUS_STDERR)).toBe(true) + }) + + test('detects older "unsafe repository" wording at exit code 128', () => { + expect(isDubiousOwnershipError(128, UNSAFE_STDERR)).toBe(true) + }) + + test('does not match other git errors at exit code 128', () => { + expect(isDubiousOwnershipError(128, 'fatal: not a git repository')).toBe(false) + }) + + test('does not match dubious ownership text at other exit codes', () => { + expect(isDubiousOwnershipError(1, DUBIOUS_STDERR)).toBe(false) + expect(isDubiousOwnershipError(0, DUBIOUS_STDERR)).toBe(false) + }) + + test('parseRepositoryPath extracts path from both wordings', () => { + expect(parseRepositoryPath(DUBIOUS_STDERR)).toBe('/github/workspace') + expect(parseRepositoryPath(UNSAFE_STDERR)).toBe('/github/workspace') + expect(parseRepositoryPath('fatal: not a git repository')).toBeUndefined() + }) +}) + +describe('createTempGitHome', () => { + const scratchDirs: string[] = [] + + async function makeScratchDir(): Promise { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safe-directory-test-')) + scratchDirs.push(dir) + return dir + } + + afterEach(async () => { + for (const dir of scratchDirs.splice(0)) { + await fs.promises.rm(dir, {recursive: true, force: true}) + } + }) + + test('copies file referenced by GIT_CONFIG_GLOBAL and skips XDG fallback', async () => { + const base = await makeScratchDir() + const home = await makeScratchDir() + const configFile = path.join(home, 'custom-gitconfig') + await fs.promises.writeFile(configFile, 'custom') + await fs.promises.mkdir(path.join(home, '.config', 'git'), {recursive: true}) + await fs.promises.writeFile(path.join(home, '.config', 'git', 'config'), 'xdg') + + const tempHome = await createTempGitHome(base, {GIT_CONFIG_GLOBAL: configFile, HOME: home}) + scratchDirs.push(tempHome) + + expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('custom') + expect(fs.existsSync(path.join(tempHome, '.config', 'git', 'config'))).toBe(false) + }) + + test('does not throw when GIT_CONFIG_GLOBAL references missing file', async () => { + const base = await makeScratchDir() + + const tempHome = await createTempGitHome(base, {GIT_CONFIG_GLOBAL: path.join(base, 'missing-gitconfig')}) + scratchDirs.push(tempHome) + + expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('') + }) + + test('copies $HOME/.gitconfig', async () => { + const base = await makeScratchDir() + const home = await makeScratchDir() + await fs.promises.writeFile(path.join(home, '.gitconfig'), 'home config') + + const tempHome = await createTempGitHome(base, {HOME: home}) + scratchDirs.push(tempHome) + + expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('home config') + }) + + test('copies XDG fallback config only when XDG_CONFIG_HOME is unset', async () => { + const base = await makeScratchDir() + const home = await makeScratchDir() + await fs.promises.mkdir(path.join(home, '.config', 'git'), {recursive: true}) + await fs.promises.writeFile(path.join(home, '.config', 'git', 'config'), 'xdg config') + + const tempHome = await createTempGitHome(base, {HOME: home}) + scratchDirs.push(tempHome) + expect(await fs.promises.readFile(path.join(tempHome, '.config', 'git', 'config'), 'utf8')).toBe('xdg config') + + const tempHomeWithXdg = await createTempGitHome(base, {HOME: home, XDG_CONFIG_HOME: path.join(home, '.config')}) + scratchDirs.push(tempHomeWithXdg) + expect(fs.existsSync(path.join(tempHomeWithXdg, '.config', 'git', 'config'))).toBe(false) + }) + + test('creates an empty .gitconfig even when there is no config to copy', async () => { + const base = await makeScratchDir() + + const tempHome = await createTempGitHome(base, {}) + scratchDirs.push(tempHome) + + expect(await fs.promises.readdir(tempHome)).toEqual(['.gitconfig']) + expect(await fs.promises.readFile(path.join(tempHome, '.gitconfig'), 'utf8')).toBe('') + }) +}) + +describe('buildGitEnv', () => { + test('overrides HOME and GIT_CONFIG_GLOBAL, preserves other variables, drops undefined values', () => { + const env = buildGitEnv('/temp/home', { + HOME: '/root', + GIT_CONFIG_GLOBAL: '/root/.gitconfig', + PATH: '/usr/bin', + UNDEFINED_VALUE: undefined + }) + + expect(env['HOME']).toBe('/temp/home') + expect(env['GIT_CONFIG_GLOBAL']).toBe(path.join('/temp/home', '.gitconfig')) + expect(env['PATH']).toBe('/usr/bin') + expect('UNDEFINED_VALUE' in env).toBe(false) + }) + + test('leaves GIT_CONFIG_GLOBAL unset when not present in the original environment', () => { + const env = buildGitEnv('/temp/home', {HOME: '/root', PATH: '/usr/bin'}) + + expect(env['HOME']).toBe('/temp/home') + expect('GIT_CONFIG_GLOBAL' in env).toBe(false) + }) +}) + +describe('resolveTempBaseDir', () => { + test('prefers RUNNER_TEMP and falls back to os.tmpdir()', () => { + expect(resolveTempBaseDir({RUNNER_TEMP: '/runner/temp'})).toBe('/runner/temp') + expect(resolveTempBaseDir({RUNNER_TEMP: ''})).toBe(os.tmpdir()) + expect(resolveTempBaseDir({})).toBe(os.tmpdir()) + }) +}) + +describe('ensureSafeDirectory', () => { + const envBackup = process.env + let runnerTemp: string + + beforeEach(async () => { + runnerTemp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'safe-directory-test-runner-')) + process.env = {...envBackup} + process.env['RUNNER_TEMP'] = runnerTemp + process.env['HOME'] = runnerTemp + process.env['GITHUB_WORKSPACE'] = process.cwd() + delete process.env['GIT_CONFIG_GLOBAL'] + delete process.env['XDG_CONFIG_HOME'] + }) + + afterEach(async () => { + await cleanup() + await fs.promises.rm(runnerTemp, {recursive: true, force: true}) + process.env = envBackup + }) + + test('activates temporary HOME and adds reported directories on first call', async () => { + expect(getGitEnv()['HOME']).not.toContain('paths-filter-git-home-') + + const added = await ensureSafeDirectory(DUBIOUS_STDERR) + + expect(added).toBe(true) + expect(getGitEnv()).toEqual( + expect.objectContaining({ + HOME: expect.stringContaining('paths-filter-git-home-') + }) + ) + // GIT_CONFIG_GLOBAL was not set in the original environment, so it must stay unset + expect(getGitEnv()).not.toHaveProperty('GIT_CONFIG_GLOBAL') + expect(execMock).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--add', 'safe.directory', '/github/workspace'], + expect.objectContaining({ + env: expect.objectContaining({HOME: expect.stringContaining('paths-filter-git-home-')}) + }) + ) + expect(execMock).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--add', 'safe.directory', process.cwd()], + expect.anything() + ) + }) + + test('returns false when repeated stderr adds no new directory', async () => { + await ensureSafeDirectory(DUBIOUS_STDERR) + const callCount = execMock.mock.calls.length + + const added = await ensureSafeDirectory(DUBIOUS_STDERR) + + expect(added).toBe(false) + expect(execMock.mock.calls.length).toBe(callCount) + }) + + test('adds directory reported by a later error for a different path', async () => { + await ensureSafeDirectory(DUBIOUS_STDERR) + + const added = await ensureSafeDirectory("fatal: detected dubious ownership in repository at '/other/repo'") + + expect(added).toBe(true) + expect(execMock).toHaveBeenCalledWith( + 'git', + ['config', '--global', '--add', 'safe.directory', '/other/repo'], + expect.anything() + ) + }) + + test('cleanup removes the temporary HOME and resets state', async () => { + await ensureSafeDirectory(DUBIOUS_STDERR) + const tempHome = getGitEnv()['HOME'] + expect(tempHome).toContain('paths-filter-git-home-') + + await cleanup() + + expect(getGitEnv()['HOME']).not.toContain('paths-filter-git-home-') + expect(fs.existsSync(tempHome)).toBe(false) + }) + + test('getGitEnv mirrors process.env and forces LC_ALL=C before activation', () => { + process.env['SOME_PRESERVED_VARIABLE'] = 'preserved' + process.env['LC_ALL'] = 'de_DE.UTF-8' + + const env = getGitEnv() + + expect(env['SOME_PRESERVED_VARIABLE']).toBe('preserved') + expect(env['HOME']).toBe(runnerTemp) + expect(env['LC_ALL']).toBe('C') + }) + + test('getGitEnv contains the temporary HOME and forces LC_ALL=C after activation', async () => { + process.env['LC_ALL'] = 'de_DE.UTF-8' + + await ensureSafeDirectory(DUBIOUS_STDERR) + + const env = getGitEnv() + expect(env['HOME']).toContain('paths-filter-git-home-') + expect(env['LC_ALL']).toBe('C') + }) +}) diff --git a/dist/index.js b/dist/index.js index 3365cd3..a4eba75 100644 --- a/dist/index.js +++ b/dist/index.js @@ -204,17 +204,46 @@ var __importStar = (this && this.__importStar) || function (mod) { return result; }; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.HEAD = exports.NULL_SHA = void 0; +exports.isGitSha = exports.getShortName = exports.getCurrentRef = exports.listAllFilesAsAdded = exports.parseGitDiffOutput = exports.getChangesSinceMergeBase = exports.getChangesOnHead = exports.getChanges = exports.getChangesInLastCommit = exports.gitExec = exports.HEAD = exports.NULL_SHA = void 0; const exec_1 = __nccwpck_require__(1514); const core = __importStar(__nccwpck_require__(2186)); const file_1 = __nccwpck_require__(4014); +const safe_directory_1 = __nccwpck_require__(2126); exports.NULL_SHA = '0000000000000000000000000000000000000000'; exports.HEAD = 'HEAD'; +async function gitExec(args, options) { + var _a; + // ignoreReturnCode is always set so exitCode and stderr stay inspectable - failures are re-thrown below + const execute = async () => (0, exec_1.getExecOutput)('git', args, { ...options, ignoreReturnCode: true, env: (0, safe_directory_1.getGitEnv)() }); + let result = await execute(); + if ((0, safe_directory_1.isDubiousOwnershipError)(result.exitCode, result.stderr)) { + if (await (0, safe_directory_1.ensureSafeDirectory)(result.stderr)) { + result = await execute(); + } + if ((0, safe_directory_1.isDubiousOwnershipError)(result.exitCode, result.stderr)) { + const firstLine = (_a = result.stderr + .split(/\r?\n/) + .find(line => line.trim().length > 0)) === null || _a === void 0 ? void 0 : _a.trim(); + throw new Error(`${firstLine !== null && firstLine !== void 0 ? firstLine : 'Git failed due to dubious repository ownership'}\n` + + 'The automatic safe.directory workaround was not sufficient. ' + + 'Either run the container with the same user as the runner:\n' + + ' container:\n' + + ' options: --user 1001\n' + + 'or mark the repository as safe in a step before this action:\n' + + ' - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"'); + } + } + if (result.exitCode !== 0 && !(options === null || options === void 0 ? void 0 : options.ignoreReturnCode)) { + throw new Error(`The process 'git ${args.join(' ')}' failed with exit code ${result.exitCode}`); + } + return result; +} +exports.gitExec = gitExec; async function getChangesInLastCommit() { core.startGroup(`Change detection in last commit`); let output = ''; try { - output = (await (0, exec_1.getExecOutput)('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout; + output = (await gitExec(['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout; } finally { fixStdOutNullTermination(); @@ -231,8 +260,7 @@ async function getChanges(base, head) { let output = ''; try { // Two dots '..' change detection - directly compares two versions - output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])) - .stdout; + output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout; } finally { fixStdOutNullTermination(); @@ -246,7 +274,7 @@ async function getChangesOnHead() { core.startGroup(`Change detection on HEAD`); let output = ''; try { - output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout; + output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout; } finally { fixStdOutNullTermination(); @@ -262,7 +290,7 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) { if (baseRef === undefined || headRef === undefined) { return false; } - return (await (0, exec_1.getExecOutput)('git', ['merge-base', baseRef, headRef], { ignoreReturnCode: true })).exitCode === 0; + return (await gitExec(['merge-base', baseRef, headRef], { ignoreReturnCode: true })).exitCode === 0; } let noMergeBase = false; core.startGroup(`Searching for merge-base ${base}...${head}`); @@ -270,12 +298,12 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) { baseRef = await getLocalRef(base); headRef = await getLocalRef(head); if (!(await hasMergeBase())) { - await (0, exec_1.getExecOutput)('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]); + await gitExec(['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]); if (baseRef === undefined || headRef === undefined) { baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base)); headRef = headRef !== null && headRef !== void 0 ? headRef : (await getLocalRef(head)); if (baseRef === undefined || headRef === undefined) { - await (0, exec_1.getExecOutput)('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], { + await gitExec(['fetch', '--tags', '--depth=1', 'origin', base, head], { ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it }); baseRef = baseRef !== null && baseRef !== void 0 ? baseRef : (await getLocalRef(base)); @@ -292,12 +320,12 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) { let lastCommitCount = await getCommitCount(); while (!(await hasMergeBase())) { depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER); - await (0, exec_1.getExecOutput)('git', ['fetch', `--deepen=${depth}`, 'origin', base, head]); + await gitExec(['fetch', `--deepen=${depth}`, 'origin', base, head]); const commitCount = await getCommitCount(); if (commitCount === lastCommitCount) { core.info('No more commits were fetched'); core.info('Last attempt will be to fetch full history'); - await (0, exec_1.getExecOutput)('git', ['fetch']); + await gitExec(['fetch']); if (!(await hasMergeBase())) { noMergeBase = true; } @@ -320,7 +348,7 @@ async function getChangesSinceMergeBase(base, head, initialFetchDepth) { core.startGroup(`Change detection ${diffArg}`); let output = ''; try { - output = (await (0, exec_1.getExecOutput)('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout; + output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout; } finally { fixStdOutNullTermination(); @@ -345,7 +373,7 @@ async function listAllFilesAsAdded() { core.startGroup('Listing all files tracked by git'); let output = ''; try { - output = (await (0, exec_1.getExecOutput)('git', ['ls-files', '-z'])).stdout; + output = (await gitExec(['ls-files', '-z'])).stdout; } finally { fixStdOutNullTermination(); @@ -363,15 +391,15 @@ exports.listAllFilesAsAdded = listAllFilesAsAdded; async function getCurrentRef() { core.startGroup(`Get current git ref`); try { - const branch = (await (0, exec_1.getExecOutput)('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim(); + const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim(); if (branch && branch !== 'HEAD') { return branch; } - const describe = await (0, exec_1.getExecOutput)('git', ['describe', '--tags', '--exact-match'], { ignoreReturnCode: true }); + const describe = await gitExec(['describe', '--tags', '--exact-match'], { ignoreReturnCode: true }); if (describe.exitCode === 0) { return describe.stdout.trim(); } - return (await (0, exec_1.getExecOutput)('git', ['rev-parse', exports.HEAD])).stdout.trim(); + return (await gitExec(['rev-parse', exports.HEAD])).stdout.trim(); } finally { core.endGroup(); @@ -395,10 +423,10 @@ function isGitSha(ref) { } exports.isGitSha = isGitSha; async function hasCommit(ref) { - return (await (0, exec_1.getExecOutput)('git', ['cat-file', '-e', `${ref}^{commit}`], { ignoreReturnCode: true })).exitCode === 0; + return (await gitExec(['cat-file', '-e', `${ref}^{commit}`], { ignoreReturnCode: true })).exitCode === 0; } async function getCommitCount() { - const output = (await (0, exec_1.getExecOutput)('git', ['rev-list', '--count', '--all'])).stdout; + const output = (await gitExec(['rev-list', '--count', '--all'])).stdout; const count = parseInt(output); return isNaN(count) ? 0 : count; } @@ -406,7 +434,7 @@ async function getLocalRef(shortName) { if (isGitSha(shortName)) { return (await hasCommit(shortName)) ? shortName : undefined; } - const output = (await (0, exec_1.getExecOutput)('git', ['show-ref', shortName], { ignoreReturnCode: true })).stdout; + const output = (await gitExec(['show-ref', shortName], { ignoreReturnCode: true })).stdout; const refs = output .split(/\r?\n/g) .map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/)) @@ -426,10 +454,10 @@ async function ensureRefAvailable(name) { try { let ref = await getLocalRef(name); if (ref === undefined) { - await (0, exec_1.getExecOutput)('git', ['fetch', '--depth=1', '--no-tags', 'origin', name]); + await gitExec(['fetch', '--depth=1', '--no-tags', 'origin', name]); ref = await getLocalRef(name); if (ref === undefined) { - await (0, exec_1.getExecOutput)('git', ['fetch', '--depth=1', '--tags', 'origin', name]); + await gitExec(['fetch', '--depth=1', '--tags', 'origin', name]); ref = await getLocalRef(name); if (ref === undefined) { throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`); @@ -559,6 +587,7 @@ const github = __importStar(__nccwpck_require__(5438)); const filter_1 = __nccwpck_require__(3707); const file_1 = __nccwpck_require__(4014); const git = __importStar(__nccwpck_require__(3374)); +const safe_directory_1 = __nccwpck_require__(2126); const shell_escape_1 = __nccwpck_require__(4613); const csv_escape_1 = __nccwpck_require__(7402); async function run() { @@ -594,6 +623,9 @@ async function run() { catch (error) { core.setFailed(getErrorMessage(error)); } + finally { + await (0, safe_directory_1.cleanup)(); + } } function isPathInput(text) { return !(text.includes('\n') || text.includes(':')); @@ -817,6 +849,164 @@ function getErrorMessage(error) { run(); +/***/ }), + +/***/ 2126: +/***/ (function(__unused_webpack_module, exports, __nccwpck_require__) { + +"use strict"; + +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.resolveTempBaseDir = exports.buildGitEnv = exports.createTempGitHome = exports.cleanup = exports.ensureSafeDirectory = exports.getGitEnv = exports.parseRepositoryPath = exports.isDubiousOwnershipError = void 0; +const fs = __importStar(__nccwpck_require__(7147)); +const os = __importStar(__nccwpck_require__(2037)); +const path = __importStar(__nccwpck_require__(1017)); +const core = __importStar(__nccwpck_require__(2186)); +const exec_1 = __nccwpck_require__(1514); +// Git >= 2.35.2 and distro backports of CVE-2022-24765 fail with exit code 128 when +// the repository is owned by a different user - typical for container jobs where the +// workspace is bind-mounted from the host. Older backports use the "unsafe repository" wording. +const DUBIOUS_OWNERSHIP_PATTERN = /detected dubious ownership|unsafe repository/; +const REPOSITORY_PATH_PATTERN = /(?:repository at|unsafe repository \()\s*'([^']+)'/; +let tempHomeDir; +let gitEnv; +const safeDirectories = new Set(); +function isDubiousOwnershipError(exitCode, stderr) { + return exitCode === 128 && DUBIOUS_OWNERSHIP_PATTERN.test(stderr); +} +exports.isDubiousOwnershipError = isDubiousOwnershipError; +function parseRepositoryPath(stderr) { + var _a; + return (_a = stderr.match(REPOSITORY_PATH_PATTERN)) === null || _a === void 0 ? void 0 : _a[1]; +} +exports.parseRepositoryPath = parseRepositoryPath; +// Until the workaround is activated this mirrors process.env; afterwards it applies the +// temporary HOME redirect. In both cases LC_ALL=C is forced so git emits untranslated +// messages and isDubiousOwnershipError / parseRepositoryPath match regardless of the +// container's locale. +function getGitEnv() { + return { ...(gitEnv !== null && gitEnv !== void 0 ? gitEnv : cloneDefinedEnv(process.env)), LC_ALL: 'C' }; +} +exports.getGitEnv = getGitEnv; +// Marks directories reported by git as safe, using a temporary HOME so no configuration +// outside this action is modified - same technique as actions/checkout. +// Returns false if there was no new directory to add. +async function ensureSafeDirectory(stderr) { + if (tempHomeDir === undefined) { + tempHomeDir = await createTempGitHome(resolveTempBaseDir(process.env), process.env); + gitEnv = buildGitEnv(tempHomeDir, process.env); + core.info('Git reported dubious ownership of the repository - this is typical for container jobs ' + + 'where the workspace is owned by a different user. A temporary HOME with a copy of the global ' + + 'git config and a safe.directory exception will be used for git commands executed by this action.'); + } + let added = false; + for (const dir of [parseRepositoryPath(stderr), process.env.GITHUB_WORKSPACE, process.cwd()]) { + if (dir && !safeDirectories.has(dir)) { + await (0, exec_1.exec)('git', ['config', '--global', '--add', 'safe.directory', dir], { env: getGitEnv() }); + safeDirectories.add(dir); + added = true; + } + } + return added; +} +exports.ensureSafeDirectory = ensureSafeDirectory; +async function cleanup() { + if (tempHomeDir !== undefined) { + try { + await fs.promises.rm(tempHomeDir, { recursive: true, force: true }); + } + catch (error) { + // Cleanup failure is not fatal - RUNNER_TEMP is wiped when the job ends + } + } + tempHomeDir = undefined; + gitEnv = undefined; + safeDirectories.clear(); +} +exports.cleanup = cleanup; +// Exported for tests +async function createTempGitHome(baseTempDir, env) { + const tempHome = await fs.promises.mkdtemp(path.join(baseTempDir, 'paths-filter-git-home-')); + const tempConfigPath = path.join(tempHome, '.gitconfig'); + // The file must exist even when there is no config to copy - when $HOME/.gitconfig is missing, + // `git config --global` writes to an existing $XDG_CONFIG_HOME/git/config instead + await fs.promises.writeFile(tempConfigPath, ''); + if (env.GIT_CONFIG_GLOBAL) { + await copyFileIfExists(env.GIT_CONFIG_GLOBAL, tempConfigPath); + } + else if (env.HOME) { + await copyFileIfExists(path.join(env.HOME, '.gitconfig'), tempConfigPath); + if (!env.XDG_CONFIG_HOME) { + // When XDG_CONFIG_HOME is unset, git falls back to $HOME/.config/git/config, + // which would become unreadable under the new HOME + await copyFileIfExists(path.join(env.HOME, '.config', 'git', 'config'), path.join(tempHome, '.config', 'git', 'config')); + } + } + return tempHome; +} +exports.createTempGitHome = createTempGitHome; +// Exported for tests +function buildGitEnv(tempHome, env) { + const newEnv = cloneDefinedEnv(env); + // A changed HOME redirects git of any version to the temp config. GIT_CONFIG_GLOBAL is redirected + // only when already set - on git >= 2.32 it replaces both global config files, so setting it + // unconditionally would hide an existing $XDG_CONFIG_HOME/git/config from git + newEnv['HOME'] = tempHome; + if (env.GIT_CONFIG_GLOBAL) { + newEnv['GIT_CONFIG_GLOBAL'] = path.join(tempHome, '.gitconfig'); + } + return newEnv; +} +exports.buildGitEnv = buildGitEnv; +// Exported for tests +function resolveTempBaseDir(env) { + return env.RUNNER_TEMP || os.tmpdir(); +} +exports.resolveTempBaseDir = resolveTempBaseDir; +function cloneDefinedEnv(env) { + const newEnv = {}; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + newEnv[key] = value; + } + } + return newEnv; +} +async function copyFileIfExists(source, destination) { + try { + await fs.promises.access(source, fs.constants.R_OK); + } + catch (error) { + return; + } + await fs.promises.mkdir(path.dirname(destination), { recursive: true }); + await fs.promises.copyFile(source, destination); +} + + /***/ }), /***/ 7351: diff --git a/src/git.ts b/src/git.ts index 9615f8b..5c19f82 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,15 +1,50 @@ -import {getExecOutput} from '@actions/exec' +import {getExecOutput, ExecOutput} from '@actions/exec' import * as core from '@actions/core' import {File, ChangeStatus} from './file' +import {ensureSafeDirectory, getGitEnv, isDubiousOwnershipError} from './safe-directory' export const NULL_SHA = '0000000000000000000000000000000000000000' export const HEAD = 'HEAD' +export async function gitExec(args: string[], options?: {ignoreReturnCode?: boolean}): Promise { + // ignoreReturnCode is always set so exitCode and stderr stay inspectable - failures are re-thrown below + const execute = async (): Promise => + getExecOutput('git', args, {...options, ignoreReturnCode: true, env: getGitEnv()}) + + let result = await execute() + if (isDubiousOwnershipError(result.exitCode, result.stderr)) { + if (await ensureSafeDirectory(result.stderr)) { + result = await execute() + } + if (isDubiousOwnershipError(result.exitCode, result.stderr)) { + const firstLine = result.stderr + .split(/\r?\n/) + .find(line => line.trim().length > 0) + ?.trim() + throw new Error( + `${firstLine ?? 'Git failed due to dubious repository ownership'}\n` + + 'The automatic safe.directory workaround was not sufficient. ' + + 'Either run the container with the same user as the runner:\n' + + ' container:\n' + + ' options: --user 1001\n' + + 'or mark the repository as safe in a step before this action:\n' + + ' - run: git config --global --add safe.directory "$GITHUB_WORKSPACE"' + ) + } + } + + if (result.exitCode !== 0 && !options?.ignoreReturnCode) { + throw new Error(`The process 'git ${args.join(' ')}' failed with exit code ${result.exitCode}`) + } + + return result +} + export async function getChangesInLastCommit(): Promise { core.startGroup(`Change detection in last commit`) let output = '' try { - output = (await getExecOutput('git', ['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout + output = (await gitExec(['log', '--format=', '--no-renames', '--name-status', '-z', '-n', '1'])).stdout } finally { fixStdOutNullTermination() core.endGroup() @@ -27,8 +62,7 @@ export async function getChanges(base: string, head: string): Promise { let output = '' try { // Two dots '..' change detection - directly compares two versions - output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])) - .stdout + output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', `${baseRef}..${headRef}`])).stdout } finally { fixStdOutNullTermination() core.endGroup() @@ -42,7 +76,7 @@ export async function getChangesOnHead(): Promise { core.startGroup(`Change detection on HEAD`) let output = '' try { - output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout + output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', 'HEAD'])).stdout } finally { fixStdOutNullTermination() core.endGroup() @@ -58,7 +92,7 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi if (baseRef === undefined || headRef === undefined) { return false } - return (await getExecOutput('git', ['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0 + return (await gitExec(['merge-base', baseRef, headRef], {ignoreReturnCode: true})).exitCode === 0 } let noMergeBase = false @@ -67,12 +101,12 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi baseRef = await getLocalRef(base) headRef = await getLocalRef(head) if (!(await hasMergeBase())) { - await getExecOutput('git', ['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]) + await gitExec(['fetch', '--no-tags', `--depth=${initialFetchDepth}`, 'origin', base, head]) if (baseRef === undefined || headRef === undefined) { baseRef = baseRef ?? (await getLocalRef(base)) headRef = headRef ?? (await getLocalRef(head)) if (baseRef === undefined || headRef === undefined) { - await getExecOutput('git', ['fetch', '--tags', '--depth=1', 'origin', base, head], { + await gitExec(['fetch', '--tags', '--depth=1', 'origin', base, head], { ignoreReturnCode: true // returns exit code 1 if tags on remote were updated - we can safely ignore it }) baseRef = baseRef ?? (await getLocalRef(base)) @@ -94,12 +128,12 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi let lastCommitCount = await getCommitCount() while (!(await hasMergeBase())) { depth = Math.min(depth * 2, Number.MAX_SAFE_INTEGER) - await getExecOutput('git', ['fetch', `--deepen=${depth}`, 'origin', base, head]) + await gitExec(['fetch', `--deepen=${depth}`, 'origin', base, head]) const commitCount = await getCommitCount() if (commitCount === lastCommitCount) { core.info('No more commits were fetched') core.info('Last attempt will be to fetch full history') - await getExecOutput('git', ['fetch']) + await gitExec(['fetch']) if (!(await hasMergeBase())) { noMergeBase = true } @@ -123,7 +157,7 @@ export async function getChangesSinceMergeBase(base: string, head: string, initi core.startGroup(`Change detection ${diffArg}`) let output = '' try { - output = (await getExecOutput('git', ['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout + output = (await gitExec(['diff', '--no-renames', '--name-status', '-z', diffArg])).stdout } finally { fixStdOutNullTermination() core.endGroup() @@ -148,7 +182,7 @@ export async function listAllFilesAsAdded(): Promise { core.startGroup('Listing all files tracked by git') let output = '' try { - output = (await getExecOutput('git', ['ls-files', '-z'])).stdout + output = (await gitExec(['ls-files', '-z'])).stdout } finally { fixStdOutNullTermination() core.endGroup() @@ -166,17 +200,17 @@ export async function listAllFilesAsAdded(): Promise { export async function getCurrentRef(): Promise { core.startGroup(`Get current git ref`) try { - const branch = (await getExecOutput('git', ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() + const branch = (await gitExec(['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() if (branch && branch !== 'HEAD') { return branch } - const describe = await getExecOutput('git', ['describe', '--tags', '--exact-match'], {ignoreReturnCode: true}) + const describe = await gitExec(['describe', '--tags', '--exact-match'], {ignoreReturnCode: true}) if (describe.exitCode === 0) { return describe.stdout.trim() } - return (await getExecOutput('git', ['rev-parse', HEAD])).stdout.trim() + return (await gitExec(['rev-parse', HEAD])).stdout.trim() } finally { core.endGroup() } @@ -199,11 +233,11 @@ export function isGitSha(ref: string): boolean { } async function hasCommit(ref: string): Promise { - return (await getExecOutput('git', ['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0 + return (await gitExec(['cat-file', '-e', `${ref}^{commit}`], {ignoreReturnCode: true})).exitCode === 0 } async function getCommitCount(): Promise { - const output = (await getExecOutput('git', ['rev-list', '--count', '--all'])).stdout + const output = (await gitExec(['rev-list', '--count', '--all'])).stdout const count = parseInt(output) return isNaN(count) ? 0 : count } @@ -213,7 +247,7 @@ async function getLocalRef(shortName: string): Promise { return (await hasCommit(shortName)) ? shortName : undefined } - const output = (await getExecOutput('git', ['show-ref', shortName], {ignoreReturnCode: true})).stdout + const output = (await gitExec(['show-ref', shortName], {ignoreReturnCode: true})).stdout const refs = output .split(/\r?\n/g) .map(l => l.match(/refs\/(?:(?:heads)|(?:tags)|(?:remotes\/origin))\/(.*)$/)) @@ -237,10 +271,10 @@ async function ensureRefAvailable(name: string): Promise { try { let ref = await getLocalRef(name) if (ref === undefined) { - await getExecOutput('git', ['fetch', '--depth=1', '--no-tags', 'origin', name]) + await gitExec(['fetch', '--depth=1', '--no-tags', 'origin', name]) ref = await getLocalRef(name) if (ref === undefined) { - await getExecOutput('git', ['fetch', '--depth=1', '--tags', 'origin', name]) + await gitExec(['fetch', '--depth=1', '--tags', 'origin', name]) ref = await getLocalRef(name) if (ref === undefined) { throw new Error(`Could not determine what is ${name} - fetch works but it's not a branch, tag or commit SHA`) diff --git a/src/main.ts b/src/main.ts index 8adb308..791d8e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import { } from './filter' import {File, ChangeStatus} from './file' import * as git from './git' +import {cleanup as cleanupSafeDirectory} from './safe-directory' import {backslashEscape, shellEscape} from './list-format/shell-escape' import {csvEscape} from './list-format/csv-escape' @@ -55,6 +56,8 @@ async function run(): Promise { exportResults(results, listFiles) } catch (error) { core.setFailed(getErrorMessage(error)) + } finally { + await cleanupSafeDirectory() } } diff --git a/src/safe-directory.ts b/src/safe-directory.ts new file mode 100644 index 0000000..bb218e6 --- /dev/null +++ b/src/safe-directory.ts @@ -0,0 +1,135 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import * as core from '@actions/core' +import {exec} from '@actions/exec' + +// Git >= 2.35.2 and distro backports of CVE-2022-24765 fail with exit code 128 when +// the repository is owned by a different user - typical for container jobs where the +// workspace is bind-mounted from the host. Older backports use the "unsafe repository" wording. +const DUBIOUS_OWNERSHIP_PATTERN = /detected dubious ownership|unsafe repository/ +const REPOSITORY_PATH_PATTERN = /(?:repository at|unsafe repository \()\s*'([^']+)'/ + +let tempHomeDir: string | undefined +let gitEnv: {[key: string]: string} | undefined +const safeDirectories = new Set() + +export function isDubiousOwnershipError(exitCode: number, stderr: string): boolean { + return exitCode === 128 && DUBIOUS_OWNERSHIP_PATTERN.test(stderr) +} + +export function parseRepositoryPath(stderr: string): string | undefined { + return stderr.match(REPOSITORY_PATH_PATTERN)?.[1] +} + +// Until the workaround is activated this mirrors process.env; afterwards it applies the +// temporary HOME redirect. In both cases LC_ALL=C is forced so git emits untranslated +// messages and isDubiousOwnershipError / parseRepositoryPath match regardless of the +// container's locale. +export function getGitEnv(): {[key: string]: string} { + return {...(gitEnv ?? cloneDefinedEnv(process.env)), LC_ALL: 'C'} +} + +// Marks directories reported by git as safe, using a temporary HOME so no configuration +// outside this action is modified - same technique as actions/checkout. +// Returns false if there was no new directory to add. +export async function ensureSafeDirectory(stderr: string): Promise { + if (tempHomeDir === undefined) { + tempHomeDir = await createTempGitHome(resolveTempBaseDir(process.env), process.env) + gitEnv = buildGitEnv(tempHomeDir, process.env) + core.info( + 'Git reported dubious ownership of the repository - this is typical for container jobs ' + + 'where the workspace is owned by a different user. A temporary HOME with a copy of the global ' + + 'git config and a safe.directory exception will be used for git commands executed by this action.' + ) + } + + let added = false + for (const dir of [parseRepositoryPath(stderr), process.env.GITHUB_WORKSPACE, process.cwd()]) { + if (dir && !safeDirectories.has(dir)) { + await exec('git', ['config', '--global', '--add', 'safe.directory', dir], {env: getGitEnv()}) + safeDirectories.add(dir) + added = true + } + } + return added +} + +export async function cleanup(): Promise { + if (tempHomeDir !== undefined) { + try { + await fs.promises.rm(tempHomeDir, {recursive: true, force: true}) + } catch (error) { + // Cleanup failure is not fatal - RUNNER_TEMP is wiped when the job ends + } + } + tempHomeDir = undefined + gitEnv = undefined + safeDirectories.clear() +} + +// Exported for tests +export async function createTempGitHome( + baseTempDir: string, + env: {[key: string]: string | undefined} +): Promise { + const tempHome = await fs.promises.mkdtemp(path.join(baseTempDir, 'paths-filter-git-home-')) + const tempConfigPath = path.join(tempHome, '.gitconfig') + // The file must exist even when there is no config to copy - when $HOME/.gitconfig is missing, + // `git config --global` writes to an existing $XDG_CONFIG_HOME/git/config instead + await fs.promises.writeFile(tempConfigPath, '') + + if (env.GIT_CONFIG_GLOBAL) { + await copyFileIfExists(env.GIT_CONFIG_GLOBAL, tempConfigPath) + } else if (env.HOME) { + await copyFileIfExists(path.join(env.HOME, '.gitconfig'), tempConfigPath) + if (!env.XDG_CONFIG_HOME) { + // When XDG_CONFIG_HOME is unset, git falls back to $HOME/.config/git/config, + // which would become unreadable under the new HOME + await copyFileIfExists( + path.join(env.HOME, '.config', 'git', 'config'), + path.join(tempHome, '.config', 'git', 'config') + ) + } + } + + return tempHome +} + +// Exported for tests +export function buildGitEnv(tempHome: string, env: {[key: string]: string | undefined}): {[key: string]: string} { + const newEnv = cloneDefinedEnv(env) + // A changed HOME redirects git of any version to the temp config. GIT_CONFIG_GLOBAL is redirected + // only when already set - on git >= 2.32 it replaces both global config files, so setting it + // unconditionally would hide an existing $XDG_CONFIG_HOME/git/config from git + newEnv['HOME'] = tempHome + if (env.GIT_CONFIG_GLOBAL) { + newEnv['GIT_CONFIG_GLOBAL'] = path.join(tempHome, '.gitconfig') + } + return newEnv +} + +// Exported for tests +export function resolveTempBaseDir(env: {[key: string]: string | undefined}): string { + return env.RUNNER_TEMP || os.tmpdir() +} + +function cloneDefinedEnv(env: {[key: string]: string | undefined}): {[key: string]: string} { + const newEnv: {[key: string]: string} = {} + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) { + newEnv[key] = value + } + } + return newEnv +} + +async function copyFileIfExists(source: string, destination: string): Promise { + try { + await fs.promises.access(source, fs.constants.R_OK) + } catch (error) { + return + } + await fs.promises.mkdir(path.dirname(destination), {recursive: true}) + await fs.promises.copyFile(source, destination) +}