From eddff1118ccd42a95b8b22b0d3bd534d2c75ca15 Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 13 Oct 2025 21:48:22 +0000 Subject: [PATCH] Persist creds to a separate file --- .github/workflows/test.yml | 9 +- __test__/git-auth-helper.test.ts | 228 ++++++++++++++-- __test__/git-directory-helper.test.ts | 4 + __test__/verify-submodules-recursive.sh | 2 +- __test__/verify-submodules-true.sh | 2 +- dist/index.js | 338 ++++++++++++++++++++---- src/git-auth-helper.ts | 332 ++++++++++++++++++----- src/git-command-manager.ts | 113 +++++++- 8 files changed, 879 insertions(+), 149 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e62ac3b..7c47d7b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -302,12 +302,15 @@ jobs: # Clone this repo - name: Checkout uses: actions/checkout@v4.1.6 + with: + path: actions-checkout # Basic checkout using git - name: Checkout basic id: checkout - uses: ./ + uses: ./actions-checkout with: + path: cloned-using-local-action ref: test-data/v2/basic # Verify output @@ -325,7 +328,3 @@ jobs: echo "Expected commit to be 82f71901cf8c021332310dcc8cdba84c4193ff5d" exit 1 fi - - # needed to make checkout post cleanup succeed - - name: Fix Checkout - uses: actions/checkout@v4.1.6 diff --git a/__test__/git-auth-helper.test.ts b/__test__/git-auth-helper.test.ts index 7633704..8275935 100644 --- a/__test__/git-auth-helper.test.ts +++ b/__test__/git-auth-helper.test.ts @@ -86,16 +86,29 @@ describe('git-auth-helper tests', () => { // Act await authHelper.configureAuth() - // Assert config - const configContent = ( + // Assert config - check that .git/config contains includeIf entries + const localConfigContent = ( await fs.promises.readFile(localGitConfigPath) ).toString() + expect( + localConfigContent.indexOf('includeIf.gitdir:') + ).toBeGreaterThanOrEqual(0) + + // Assert credentials config file contains the actual credentials + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(1) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) + ).toString() const basicCredential = Buffer.from( `x-access-token:${settings.authToken}`, 'utf8' ).toString('base64') expect( - configContent.indexOf( + credentialsContent.indexOf( `http.${expectedServerUrl}/.extraheader AUTHORIZATION: basic ${basicCredential}` ) ).toBeGreaterThanOrEqual(0) @@ -120,7 +133,7 @@ describe('git-auth-helper tests', () => { 'inject https://github.com as github server url' it(configureAuth_AcceptsGitHubServerUrlSetToGHEC, async () => { await testAuthHeader( - configureAuth_AcceptsGitHubServerUrl, + configureAuth_AcceptsGitHubServerUrlSetToGHEC, 'https://github.com' ) }) @@ -141,12 +154,17 @@ describe('git-auth-helper tests', () => { // Act await authHelper.configureAuth() - // Assert config - const configContent = ( - await fs.promises.readFile(localGitConfigPath) + // Assert config - check credentials config file (not local .git/config) + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(1) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) ).toString() expect( - configContent.indexOf( + credentialsContent.indexOf( `http.https://github.com/.extraheader AUTHORIZATION` ) ).toBeGreaterThanOrEqual(0) @@ -251,13 +269,16 @@ describe('git-auth-helper tests', () => { expectedSshCommand ) - // Asserty git config + // Assert git config const gitConfigLines = (await fs.promises.readFile(localGitConfigPath)) .toString() .split('\n') .filter(x => x) - expect(gitConfigLines).toHaveLength(1) - expect(gitConfigLines[0]).toMatch(/^http\./) + // Should have includeIf entries pointing to credentials file + expect(gitConfigLines.length).toBeGreaterThan(0) + expect( + gitConfigLines.some(line => line.indexOf('includeIf.gitdir:') >= 0) + ).toBeTruthy() }) const configureAuth_setsSshCommandWhenPersistCredentialsTrue = @@ -419,8 +440,20 @@ describe('git-auth-helper tests', () => { expect( configContent.indexOf('value-from-global-config') ).toBeGreaterThanOrEqual(0) + // Global config should have include.path pointing to credentials file + expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0) + + // Check credentials in the separate config file + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBeGreaterThan(0) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) + ).toString() expect( - configContent.indexOf( + credentialsContent.indexOf( `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}` ) ).toBeGreaterThanOrEqual(0) @@ -463,8 +496,20 @@ describe('git-auth-helper tests', () => { const configContent = ( await fs.promises.readFile(path.join(git.env['HOME'], '.gitconfig')) ).toString() + // Global config should have include.path pointing to credentials file + expect(configContent.indexOf('include.path')).toBeGreaterThanOrEqual(0) + + // Check credentials in the separate config file + const credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBeGreaterThan(0) + const credentialsConfigPath = path.join(runnerTemp, credentialsFiles[0]) + const credentialsContent = ( + await fs.promises.readFile(credentialsConfigPath) + ).toString() expect( - configContent.indexOf( + credentialsContent.indexOf( `http.https://github.com/.extraheader AUTHORIZATION: basic ${basicCredential}` ) ).toBeGreaterThanOrEqual(0) @@ -550,15 +595,15 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - expect(mockSubmoduleForeach).toHaveBeenCalledTimes(4) + // Should configure insteadOf (2 calls for two values) + expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( /unset-all.*insteadOf/ ) - expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) - expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch( + expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch( /url.*insteadOf.*git@github.com:/ ) - expect(mockSubmoduleForeach.mock.calls[3][0]).toMatch( + expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch( /url.*insteadOf.*org-123456@github.com:/ ) } @@ -589,12 +634,12 @@ describe('git-auth-helper tests', () => { await authHelper.configureSubmoduleAuth() // Assert - expect(mockSubmoduleForeach).toHaveBeenCalledTimes(3) + // Should configure sshCommand (1 call) + expect(mockSubmoduleForeach).toHaveBeenCalledTimes(2) expect(mockSubmoduleForeach.mock.calls[0][0]).toMatch( /unset-all.*insteadOf/ ) - expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/http.*extraheader/) - expect(mockSubmoduleForeach.mock.calls[2][0]).toMatch(/core\.sshCommand/) + expect(mockSubmoduleForeach.mock.calls[1][0]).toMatch(/core\.sshCommand/) } ) @@ -660,19 +705,35 @@ describe('git-auth-helper tests', () => { await setup(removeAuth_removesToken) const authHelper = gitAuthHelper.createAuthHelper(git, settings) await authHelper.configureAuth() - let gitConfigContent = ( + + // Sanity check - verify includeIf entries exist in local config + let localConfigContent = ( await fs.promises.readFile(localGitConfigPath) ).toString() - expect(gitConfigContent.indexOf('http.')).toBeGreaterThanOrEqual(0) // sanity check + expect( + localConfigContent.indexOf('includeIf.gitdir:') + ).toBeGreaterThanOrEqual(0) + + // Sanity check - verify credentials file exists + let credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(1) // Act await authHelper.removeAuth() - // Assert git config - gitConfigContent = ( + // Assert includeIf entries removed from local git config + localConfigContent = ( await fs.promises.readFile(localGitConfigPath) ).toString() - expect(gitConfigContent.indexOf('http.')).toBeLessThan(0) + expect(localConfigContent.indexOf('includeIf.gitdir:')).toBeLessThan(0) + + // Assert credentials config file deleted + credentialsFiles = (await fs.promises.readdir(runnerTemp)).filter( + f => f.startsWith('git-credentials-') && f.endsWith('.config') + ) + expect(credentialsFiles.length).toBe(0) }) const removeGlobalConfig_removesOverride = @@ -701,6 +762,52 @@ describe('git-auth-helper tests', () => { } } }) + + const testCredentialsConfigPath_matchesCredentialsConfigPaths = + 'testCredentialsConfigPath matches credentials config paths' + it(testCredentialsConfigPath_matchesCredentialsConfigPaths, async () => { + // Arrange + await setup(testCredentialsConfigPath_matchesCredentialsConfigPaths) + const authHelper = gitAuthHelper.createAuthHelper(git, settings) + + // Get a real credentials config path + const credentialsConfigPath = await ( + authHelper as any + ).getCredentialsConfigPath() + + // Act & Assert + expect( + (authHelper as any).testCredentialsConfigPath(credentialsConfigPath) + ).toBe(true) + expect( + (authHelper as any).testCredentialsConfigPath( + '/some/path/git-credentials-12345678-abcd-1234-5678-123456789012.config' + ) + ).toBe(true) + expect( + (authHelper as any).testCredentialsConfigPath( + '/some/path/git-credentials-abcdef12-3456-7890-abcd-ef1234567890.config' + ) + ).toBe(true) + + // Test invalid paths + expect( + (authHelper as any).testCredentialsConfigPath( + '/some/path/other-config.config' + ) + ).toBe(false) + expect( + (authHelper as any).testCredentialsConfigPath( + '/some/path/git-credentials-invalid.config' + ) + ).toBe(false) + expect( + (authHelper as any).testCredentialsConfigPath( + '/some/path/git-credentials-.config' + ) + ).toBe(false) + expect((authHelper as any).testCredentialsConfigPath('')).toBe(false) + }) }) async function setup(testName: string): Promise { @@ -715,6 +822,7 @@ async function setup(testName: string): Promise { await fs.promises.mkdir(tempHomedir, {recursive: true}) process.env['RUNNER_TEMP'] = runnerTemp process.env['HOME'] = tempHomedir + process.env['GITHUB_WORKSPACE'] = workspace // Create git config globalGitConfigPath = path.join(tempHomedir, '.gitconfig') @@ -733,10 +841,20 @@ async function setup(testName: string): Promise { checkout: jest.fn(), checkoutDetach: jest.fn(), config: jest.fn( - async (key: string, value: string, globalConfig?: boolean) => { - const configPath = globalConfig - ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') - : localGitConfigPath + async ( + key: string, + value: string, + globalConfig?: boolean, + add?: boolean, + configFile?: string + ) => { + const configPath = + configFile || + (globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath) + // Ensure directory exists + await fs.promises.mkdir(path.dirname(configPath), {recursive: true}) await fs.promises.appendFile(configPath, `\n${key} ${value}`) } ), @@ -756,6 +874,7 @@ async function setup(testName: string): Promise { env: {}, fetch: jest.fn(), getDefaultBranch: jest.fn(), + getSubmoduleConfigPaths: jest.fn(async () => []), getWorkingDirectory: jest.fn(() => workspace), init: jest.fn(), isDetached: jest.fn(), @@ -794,8 +913,57 @@ async function setup(testName: string): Promise { return true } ), + tryConfigUnsetValue: jest.fn( + async ( + key: string, + value: string, + globalConfig?: boolean + ): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + let content = await fs.promises.readFile(configPath) + let lines = content + .toString() + .split('\n') + .filter(x => x) + .filter(x => !(x.startsWith(key) && x.includes(value))) + await fs.promises.writeFile(configPath, lines.join('\n')) + return true + } + ), tryDisableAutomaticGarbageCollection: jest.fn(), tryGetFetchUrl: jest.fn(), + tryGetConfigValues: jest.fn( + async (key: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + const content = await fs.promises.readFile(configPath) + const lines = content + .toString() + .split('\n') + .filter(x => x && x.startsWith(key)) + .map(x => x.substring(key.length).trim()) + return lines + } + ), + tryGetConfigKeys: jest.fn( + async (pattern: string, globalConfig?: boolean): Promise => { + const configPath = globalConfig + ? path.join(git.env['HOME'] || tempHomedir, '.gitconfig') + : localGitConfigPath + const content = await fs.promises.readFile(configPath) + const lines = content + .toString() + .split('\n') + .filter(x => x) + const keys = lines + .filter(x => new RegExp(pattern).test(x.split(' ')[0])) + .map(x => x.split(' ')[0]) + return [...new Set(keys)] // Remove duplicates + } + ), tryReset: jest.fn(), version: jest.fn() } @@ -830,6 +998,7 @@ async function setup(testName: string): Promise { async function getActualSshKeyPath(): Promise { let actualTempFiles = (await fs.promises.readdir(runnerTemp)) + .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file .sort() .map(x => path.join(runnerTemp, x)) if (actualTempFiles.length === 0) { @@ -843,6 +1012,7 @@ async function getActualSshKeyPath(): Promise { async function getActualSshKnownHostsPath(): Promise { let actualTempFiles = (await fs.promises.readdir(runnerTemp)) + .filter(x => !x.startsWith('git-credentials-')) // Exclude credentials config file .sort() .map(x => path.join(runnerTemp, x)) if (actualTempFiles.length === 0) { diff --git a/__test__/git-directory-helper.test.ts b/__test__/git-directory-helper.test.ts index 22e9ae6..de79dc8 100644 --- a/__test__/git-directory-helper.test.ts +++ b/__test__/git-directory-helper.test.ts @@ -471,6 +471,7 @@ async function setup(testName: string): Promise { configExists: jest.fn(), fetch: jest.fn(), getDefaultBranch: jest.fn(), + getSubmoduleConfigPaths: jest.fn(async () => []), getWorkingDirectory: jest.fn(() => repositoryPath), init: jest.fn(), isDetached: jest.fn(), @@ -493,12 +494,15 @@ async function setup(testName: string): Promise { return true }), tryConfigUnset: jest.fn(), + tryConfigUnsetValue: jest.fn(), tryDisableAutomaticGarbageCollection: jest.fn(), tryGetFetchUrl: jest.fn(async () => { // Sanity check - this function shouldn't be called when the .git directory doesn't exist await fs.promises.stat(path.join(repositoryPath, '.git')) return repositoryUrl }), + tryGetConfigValues: jest.fn(), + tryGetConfigKeys: jest.fn(), tryReset: jest.fn(async () => { return true }), diff --git a/__test__/verify-submodules-recursive.sh b/__test__/verify-submodules-recursive.sh index 1b68f9b..5ecbb42 100755 --- a/__test__/verify-submodules-recursive.sh +++ b/__test__/verify-submodules-recursive.sh @@ -17,7 +17,7 @@ fi echo "Testing persisted credential" pushd ./submodules-recursive/submodule-level-1/submodule-level-2 -git config --local --name-only --get-regexp http.+extraheader && git fetch +git config --local --includes --name-only --get-regexp http.+extraheader && git fetch if [ "$?" != "0" ]; then echo "Failed to validate persisted credential" popd diff --git a/__test__/verify-submodules-true.sh b/__test__/verify-submodules-true.sh index 43769fe..4c311f8 100755 --- a/__test__/verify-submodules-true.sh +++ b/__test__/verify-submodules-true.sh @@ -17,7 +17,7 @@ fi echo "Testing persisted credential" pushd ./submodules-true/submodule-level-1 -git config --local --name-only --get-regexp http.+extraheader && git fetch +git config --local --includes --name-only --get-regexp http.+extraheader && git fetch if [ "$?" != "0" ]; then echo "Failed to validate persisted credential" popd diff --git a/dist/index.js b/dist/index.js index f3ae6f3..a251a19 100644 --- a/dist/index.js +++ b/dist/index.js @@ -162,6 +162,7 @@ class GitAuthHelper { this.sshKeyPath = ''; this.sshKnownHostsPath = ''; this.temporaryHomePath = ''; + this.credentialsConfigPath = ''; // Path to separate credentials config file in RUNNER_TEMP this.git = gitCommandManager; this.settings = gitSourceSettings || {}; // Token auth header @@ -229,15 +230,17 @@ class GitAuthHelper { configureGlobalAuth() { return __awaiter(this, void 0, void 0, function* () { // 'configureTempGlobalConfig' noops if already set, just returns the path - const newGitConfigPath = yield this.configureTempGlobalConfig(); + yield this.configureTempGlobalConfig(); try { // Configure the token - yield this.configureToken(newGitConfigPath, true); + yield this.configureToken(true); // Configure HTTPS instead of SSH yield this.git.tryConfigUnset(this.insteadOfKey, true); if (!this.settings.sshKey) { for (const insteadOfValue of this.insteadOfValues) { - yield this.git.config(this.insteadOfKey, insteadOfValue, true, true); + yield this.git.config(this.insteadOfKey, insteadOfValue, true, // globalConfig? + true // add? + ); } } } @@ -252,19 +255,34 @@ class GitAuthHelper { configureSubmoduleAuth() { return __awaiter(this, void 0, void 0, function* () { // Remove possible previous HTTPS instead of SSH - yield this.removeGitConfig(this.insteadOfKey, true); + yield this.removeSubmoduleGitConfig(this.insteadOfKey); if (this.settings.persistCredentials) { - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - const output = yield this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline - `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, this.settings.nestedSubmodules); - // Replace the placeholder - const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = this.getCredentialsConfigPath(); + // Container credentials config path + const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath)); + // Get submodule config file paths. + const configPaths = yield this.git.getSubmoduleConfigPaths(this.settings.nestedSubmodules); + // For each submodule, configure includeIf entries pointing to the shared credentials file. + // Configure both host and container paths to support Docker container actions. for (const configPath of configPaths) { - core.debug(`Replacing token placeholder in '${configPath}'`); - yield this.replaceTokenPlaceholder(configPath); + // Submodule Git directory + let submoduleGitDir = path.dirname(configPath); // The config file is at .git/modules/submodule-name/config + submoduleGitDir = submoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + // Configure host includeIf + yield this.git.config(`includeIf.gitdir:${submoduleGitDir}.path`, credentialsConfigPath, false, // globalConfig? + false, // add? + configPath); + // Container submodule git directory + const githubWorkspace = process.env['GITHUB_WORKSPACE']; + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); + let relativeSubmoduleGitDir = path.relative(githubWorkspace, submoduleGitDir); + relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + const containerSubmoduleGitDir = path.posix.join('/github/workspace', relativeSubmoduleGitDir); + // Configure container includeIf + yield this.git.config(`includeIf.gitdir:${containerSubmoduleGitDir}.path`, containerCredentialsPath, false, // globalConfig? + false, // add? + configPath); } if (this.settings.sshKey) { // Configure core.sshCommand @@ -295,6 +313,10 @@ class GitAuthHelper { } }); } + /** + * Configures SSH authentication by writing the SSH key and known hosts, + * and setting up the GIT_SSH_COMMAND environment variable. + */ configureSsh() { return __awaiter(this, void 0, void 0, function* () { if (!this.settings.sshKey) { @@ -351,43 +373,88 @@ class GitAuthHelper { } }); } - configureToken(configPath, globalConfig) { + /** + * Configures token-based authentication by creating a credentials config file + * and setting up includeIf entries to reference it. + * @param globalConfig Whether to configure global config instead of local + */ + configureToken(globalConfig) { return __awaiter(this, void 0, void 0, function* () { - // Validate args - assert.ok((configPath && globalConfig) || (!configPath && !globalConfig), 'Unexpected configureToken parameter combinations'); - // Default config path - if (!configPath && !globalConfig) { - configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config'); - } - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, globalConfig); - // Replace the placeholder - yield this.replaceTokenPlaceholder(configPath || ''); - }); - } - replaceTokenPlaceholder(configPath) { - return __awaiter(this, void 0, void 0, function* () { - assert.ok(configPath, 'configPath is not defined'); - let content = (yield fs.promises.readFile(configPath)).toString(); + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = this.getCredentialsConfigPath(); + // Write placeholder to the separate credentials config file using git config. + // This approach avoids the credential being captured by process creation audit events, + // which are commonly logged. For more information, refer to + // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + yield this.git.config(this.tokenConfigKey, this.tokenPlaceholderConfigValue, false, // globalConfig? + false, // add? + credentialsConfigPath); + // Replace the placeholder in the credentials config file + let content = (yield fs.promises.readFile(credentialsConfigPath)).toString(); const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue); if (placeholderIndex < 0 || placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue)) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`); + throw new Error(`Unable to replace auth placeholder in ${credentialsConfigPath}`); } assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined'); content = content.replace(this.tokenPlaceholderConfigValue, this.tokenConfigValue); - yield fs.promises.writeFile(configPath, content); + yield fs.promises.writeFile(credentialsConfigPath, content); + // Add include or includeIf to reference the credentials config + if (globalConfig) { + // Global config file is temporary + yield this.git.config('include.path', credentialsConfigPath, true // globalConfig? + ); + } + else { + // Host git directory + let gitDir = path.join(this.git.getWorkingDirectory(), '.git'); + gitDir = gitDir.replace(/\\/g, '/'); // Use forward slashes, even on Windows + // Configure host includeIf + const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`; + yield this.git.config(hostIncludeKey, credentialsConfigPath); + // Container git directory + const workingDirectory = this.git.getWorkingDirectory(); + const githubWorkspace = process.env['GITHUB_WORKSPACE']; + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined'); + let relativePath = path.relative(githubWorkspace, workingDirectory); + relativePath = relativePath.replace(/\\/g, '/'); // Use forward slashes, even on Windows + const containerGitDir = path.posix.join('/github/workspace', relativePath, '.git'); + // Container credentials config path + const containerCredentialsPath = path.posix.join('/github/runner_temp', path.basename(credentialsConfigPath)); + // Configure container includeIf + const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`; + yield this.git.config(containerIncludeKey, containerCredentialsPath); + } }); } + /** + * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * @returns The absolute path to the credentials config file + */ + getCredentialsConfigPath() { + if (this.credentialsConfigPath) { + return this.credentialsConfigPath; + } + const runnerTemp = process.env['RUNNER_TEMP'] || ''; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + // Create a unique filename for this checkout instance + const configFileName = `git-credentials-${(0, uuid_1.v4)()}.config`; + this.credentialsConfigPath = path.join(runnerTemp, configFileName); + core.debug(`Credentials config path: ${this.credentialsConfigPath}`); + return this.credentialsConfigPath; + } + /** + * Removes SSH authentication configuration by cleaning up SSH keys, + * known hosts files, and SSH command configurations. + */ removeSsh() { return __awaiter(this, void 0, void 0, function* () { - var _a; + var _a, _b; // SSH key const keyPath = this.sshKeyPath || stateHelper.SshKeyPath; if (keyPath) { try { + core.info(`Removing SSH key '${keyPath}'`); yield io.rmRF(keyPath); } catch (err) { @@ -399,37 +466,136 @@ class GitAuthHelper { const knownHostsPath = this.sshKnownHostsPath || stateHelper.SshKnownHostsPath; if (knownHostsPath) { try { + core.info(`Removing SSH known hosts '${knownHostsPath}'`); yield io.rmRF(knownHostsPath); } - catch (_b) { - // Intentionally empty + catch (err) { + core.debug(`${(_b = err === null || err === void 0 ? void 0 : err.message) !== null && _b !== void 0 ? _b : err}`); + core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`); } } // SSH command + core.info('Removing SSH command configuration'); yield this.removeGitConfig(SSH_COMMAND_KEY); + yield this.removeSubmoduleGitConfig(SSH_COMMAND_KEY); }); } + /** + * Removes token-based authentication by cleaning up HTTP headers, + * includeIf entries, and credentials config files. + */ removeToken() { return __awaiter(this, void 0, void 0, function* () { - // HTTP extra header + var _a; + // Remove HTTP extra header + core.info('Removing HTTP extra header'); yield this.removeGitConfig(this.tokenConfigKey); - }); - } - removeGitConfig(configKey_1) { - return __awaiter(this, arguments, void 0, function* (configKey, submoduleOnly = false) { - if (!submoduleOnly) { - if ((yield this.git.configExists(configKey)) && - !(yield this.git.tryConfigUnset(configKey))) { - // Load the config contents - core.warning(`Failed to remove '${configKey}' from the git config`); + yield this.removeSubmoduleGitConfig(this.tokenConfigKey); + // Collect credentials config paths that need to be removed + const credentialsPaths = new Set(); + // Remove includeIf entries that point to git-credentials-*.config files + core.info('Removing includeIf entries pointing to credentials config files'); + const mainCredentialsPaths = yield this.removeIncludeIfCredentials(); + mainCredentialsPaths.forEach(path => credentialsPaths.add(path)); + // Remove submodule includeIf entries that point to git-credentials-*.config files + const submoduleConfigPaths = yield this.git.getSubmoduleConfigPaths(true); + for (const configPath of submoduleConfigPaths) { + const submoduleCredentialsPaths = yield this.removeIncludeIfCredentials(configPath); + submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path)); + } + // Remove credentials config files + for (const credentialsPath of credentialsPaths) { + // Only remove credentials config files if they are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP']; + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined'); + if (credentialsPath.startsWith(runnerTemp)) { + try { + core.info(`Removing credentials config '${credentialsPath}'`); + yield io.rmRF(credentialsPath); + } + catch (err) { + core.debug(`${(_a = err === null || err === void 0 ? void 0 : err.message) !== null && _a !== void 0 ? _a : err}`); + core.warning(`Failed to remove credentials config '${credentialsPath}'`); + } + } + else { + core.debug(`Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP`); } } + }); + } + /** + * Removes a git config key from the local repository config. + * @param configKey The git config key to remove + */ + removeGitConfig(configKey) { + return __awaiter(this, void 0, void 0, function* () { + if ((yield this.git.configExists(configKey)) && + !(yield this.git.tryConfigUnset(configKey))) { + // Load the config contents + core.warning(`Failed to remove '${configKey}' from the git config`); + } + }); + } + /** + * Removes a git config key from all submodule configs. + * @param configKey The git config key to remove + */ + removeSubmoduleGitConfig(configKey) { + return __awaiter(this, void 0, void 0, function* () { const pattern = regexpHelper.escape(configKey); yield this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline + // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline. `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true); }); } + /** + * Removes includeIf entries that point to git-credentials-*.config files. + * @param configPath Optional path to a specific git config file to operate on + * @returns Array of unique credentials config file paths that were found and removed + */ + removeIncludeIfCredentials(configPath) { + return __awaiter(this, void 0, void 0, function* () { + const credentialsPaths = new Set(); + try { + // Get all includeIf.gitdir keys + const keys = yield this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, // globalConfig? + configPath); + for (const key of keys) { + // Get all values for this key + const values = yield this.git.tryGetConfigValues(key, false, // globalConfig? + configPath); + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + credentialsPaths.add(value); + yield this.git.tryConfigUnsetValue(key, value, false, configPath); + } + } + } + } + } + catch (err) { + // Ignore errors - this is cleanup code + if (configPath) { + core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`); + } + else { + core.debug(`Error during includeIf cleanup: ${err}`); + } + } + return Array.from(credentialsPaths); + }); + } + /** + * Tests if a path matches the git-credentials-*.config pattern. + * @param path The path to test + * @returns True if the path matches the credentials config pattern + */ + testCredentialsConfigPath(path) { + return /git-credentials-[0-9a-f-]+\.config$/i.test(path); + } } @@ -627,9 +793,15 @@ class GitCommandManager { yield this.execGit(args); }); } - config(configKey, configValue, globalConfig, add) { + config(configKey, configValue, globalConfig, add, configFile) { return __awaiter(this, void 0, void 0, function* () { - const args = ['config', globalConfig ? '--global' : '--local']; + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } if (add) { args.push('--add'); } @@ -706,6 +878,16 @@ class GitCommandManager { throw new Error('Unexpected output when retrieving default branch'); }); } + getSubmoduleConfigPaths(recursive) { + return __awaiter(this, void 0, void 0, function* () { + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. + const output = yield this.submoduleForeach(`git config --local --show-origin --name-only --get-regexp remote.origin.url`, recursive); + // Extract config file paths from the output (lines starting with "file:"). + const configPaths = output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || []; + return configPaths; + }); + } getWorkingDirectory() { return this.workingDirectory; } @@ -836,6 +1018,20 @@ class GitCommandManager { return output.exitCode === 0; }); } + tryConfigUnsetValue(configKey, configValue, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--unset', configKey, configValue); + const output = yield this.execGit(args, true); + return output.exitCode === 0; + }); + } tryDisableAutomaticGarbageCollection() { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['config', '--local', 'gc.auto', '0'], true); @@ -855,6 +1051,46 @@ class GitCommandManager { return stdout; }); } + tryGetConfigValues(configKey, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--get-all', configKey); + const output = yield this.execGit(args, true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout + .trim() + .split('\n') + .filter(value => value.trim()); + }); + } + tryGetConfigKeys(pattern, globalConfig, configFile) { + return __awaiter(this, void 0, void 0, function* () { + const args = ['config']; + if (configFile) { + args.push('--file', configFile); + } + else { + args.push(globalConfig ? '--global' : '--local'); + } + args.push('--name-only', '--get-regexp', pattern); + const output = yield this.execGit(args, true); + if (output.exitCode !== 0) { + return []; + } + return output.stdout + .trim() + .split('\n') + .filter(key => key.trim()); + }); + } tryReset() { return __awaiter(this, void 0, void 0, function* () { const output = yield this.execGit(['reset', '--hard', 'HEAD'], true); diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts index 126e8e5..a1950a6 100644 --- a/src/git-auth-helper.ts +++ b/src/git-auth-helper.ts @@ -43,6 +43,7 @@ class GitAuthHelper { private sshKeyPath = '' private sshKnownHostsPath = '' private temporaryHomePath = '' + private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP constructor( gitCommandManager: IGitCommandManager, @@ -126,16 +127,21 @@ class GitAuthHelper { async configureGlobalAuth(): Promise { // 'configureTempGlobalConfig' noops if already set, just returns the path - const newGitConfigPath = await this.configureTempGlobalConfig() + await this.configureTempGlobalConfig() try { // Configure the token - await this.configureToken(newGitConfigPath, true) + await this.configureToken(true) // Configure HTTPS instead of SSH await this.git.tryConfigUnset(this.insteadOfKey, true) if (!this.settings.sshKey) { for (const insteadOfValue of this.insteadOfValues) { - await this.git.config(this.insteadOfKey, insteadOfValue, true, true) + await this.git.config( + this.insteadOfKey, + insteadOfValue, + true, // globalConfig? + true // add? + ) } } } catch (err) { @@ -150,24 +156,60 @@ class GitAuthHelper { async configureSubmoduleAuth(): Promise { // Remove possible previous HTTPS instead of SSH - await this.removeGitConfig(this.insteadOfKey, true) + await this.removeSubmoduleGitConfig(this.insteadOfKey) if (this.settings.persistCredentials) { - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing - const output = await this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline - `sh -c "git config --local '${this.tokenConfigKey}' '${this.tokenPlaceholderConfigValue}' && git config --local --show-origin --name-only --get-regexp remote.origin.url"`, + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = this.getCredentialsConfigPath() + + // Container credentials config path + const containerCredentialsPath = path.posix.join( + '/github/runner_temp', + path.basename(credentialsConfigPath) + ) + + // Get submodule config file paths. + const configPaths = await this.git.getSubmoduleConfigPaths( this.settings.nestedSubmodules ) - // Replace the placeholder - const configPaths: string[] = - output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] + // For each submodule, configure includeIf entries pointing to the shared credentials file. + // Configure both host and container paths to support Docker container actions. for (const configPath of configPaths) { - core.debug(`Replacing token placeholder in '${configPath}'`) - await this.replaceTokenPlaceholder(configPath) + // Submodule Git directory + let submoduleGitDir = path.dirname(configPath) // The config file is at .git/modules/submodule-name/config + submoduleGitDir = submoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows + + // Configure host includeIf + await this.git.config( + `includeIf.gitdir:${submoduleGitDir}.path`, + credentialsConfigPath, + false, // globalConfig? + false, // add? + configPath + ) + + // Container submodule git directory + const githubWorkspace = process.env['GITHUB_WORKSPACE'] + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined') + let relativeSubmoduleGitDir = path.relative( + githubWorkspace, + submoduleGitDir + ) + relativeSubmoduleGitDir = relativeSubmoduleGitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows + const containerSubmoduleGitDir = path.posix.join( + '/github/workspace', + relativeSubmoduleGitDir + ) + + // Configure container includeIf + await this.git.config( + `includeIf.gitdir:${containerSubmoduleGitDir}.path`, + containerCredentialsPath, + false, // globalConfig? + false, // add? + configPath + ) } if (this.settings.sshKey) { @@ -201,6 +243,10 @@ class GitAuthHelper { } } + /** + * Configures SSH authentication by writing the SSH key and known hosts, + * and setting up the GIT_SSH_COMMAND environment variable. + */ private async configureSsh(): Promise { if (!this.settings.sshKey) { return @@ -272,57 +318,116 @@ class GitAuthHelper { } } - private async configureToken( - configPath?: string, - globalConfig?: boolean - ): Promise { - // Validate args - assert.ok( - (configPath && globalConfig) || (!configPath && !globalConfig), - 'Unexpected configureToken parameter combinations' - ) + /** + * Configures token-based authentication by creating a credentials config file + * and setting up includeIf entries to reference it. + * @param globalConfig Whether to configure global config instead of local + */ + private async configureToken(globalConfig?: boolean): Promise { + // Get the credentials config file path in RUNNER_TEMP + const credentialsConfigPath = this.getCredentialsConfigPath() - // Default config path - if (!configPath && !globalConfig) { - configPath = path.join(this.git.getWorkingDirectory(), '.git', 'config') - } - - // Configure a placeholder value. This approach avoids the credential being captured - // by process creation audit events, which are commonly logged. For more information, - // refer to https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing + // Write placeholder to the separate credentials config file using git config. + // This approach avoids the credential being captured by process creation audit events, + // which are commonly logged. For more information, refer to + // https://docs.microsoft.com/en-us/windows-server/identity/ad-ds/manage/component-updates/command-line-process-auditing await this.git.config( this.tokenConfigKey, this.tokenPlaceholderConfigValue, - globalConfig + false, // globalConfig? + false, // add? + credentialsConfigPath ) - // Replace the placeholder - await this.replaceTokenPlaceholder(configPath || '') - } - - private async replaceTokenPlaceholder(configPath: string): Promise { - assert.ok(configPath, 'configPath is not defined') - let content = (await fs.promises.readFile(configPath)).toString() + // Replace the placeholder in the credentials config file + let content = (await fs.promises.readFile(credentialsConfigPath)).toString() const placeholderIndex = content.indexOf(this.tokenPlaceholderConfigValue) if ( placeholderIndex < 0 || placeholderIndex != content.lastIndexOf(this.tokenPlaceholderConfigValue) ) { - throw new Error(`Unable to replace auth placeholder in ${configPath}`) + throw new Error( + `Unable to replace auth placeholder in ${credentialsConfigPath}` + ) } assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined') content = content.replace( this.tokenPlaceholderConfigValue, this.tokenConfigValue ) - await fs.promises.writeFile(configPath, content) + await fs.promises.writeFile(credentialsConfigPath, content) + + // Add include or includeIf to reference the credentials config + if (globalConfig) { + // Global config file is temporary + await this.git.config( + 'include.path', + credentialsConfigPath, + true // globalConfig? + ) + } else { + // Host git directory + let gitDir = path.join(this.git.getWorkingDirectory(), '.git') + gitDir = gitDir.replace(/\\/g, '/') // Use forward slashes, even on Windows + + // Configure host includeIf + const hostIncludeKey = `includeIf.gitdir:${gitDir}.path` + await this.git.config(hostIncludeKey, credentialsConfigPath) + + // Container git directory + const workingDirectory = this.git.getWorkingDirectory() + const githubWorkspace = process.env['GITHUB_WORKSPACE'] + assert.ok(githubWorkspace, 'GITHUB_WORKSPACE is not defined') + let relativePath = path.relative(githubWorkspace, workingDirectory) + relativePath = relativePath.replace(/\\/g, '/') // Use forward slashes, even on Windows + const containerGitDir = path.posix.join( + '/github/workspace', + relativePath, + '.git' + ) + + // Container credentials config path + const containerCredentialsPath = path.posix.join( + '/github/runner_temp', + path.basename(credentialsConfigPath) + ) + + // Configure container includeIf + const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path` + await this.git.config(containerIncludeKey, containerCredentialsPath) + } } + /** + * Gets or creates the path to the credentials config file in RUNNER_TEMP. + * @returns The absolute path to the credentials config file + */ + private getCredentialsConfigPath(): string { + if (this.credentialsConfigPath) { + return this.credentialsConfigPath + } + + const runnerTemp = process.env['RUNNER_TEMP'] || '' + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') + + // Create a unique filename for this checkout instance + const configFileName = `git-credentials-${uuid()}.config` + this.credentialsConfigPath = path.join(runnerTemp, configFileName) + + core.debug(`Credentials config path: ${this.credentialsConfigPath}`) + return this.credentialsConfigPath + } + + /** + * Removes SSH authentication configuration by cleaning up SSH keys, + * known hosts files, and SSH command configurations. + */ private async removeSsh(): Promise { // SSH key const keyPath = this.sshKeyPath || stateHelper.SshKeyPath if (keyPath) { try { + core.info(`Removing SSH key '${keyPath}'`) await io.rmRF(keyPath) } catch (err) { core.debug(`${(err as any)?.message ?? err}`) @@ -335,40 +440,149 @@ class GitAuthHelper { this.sshKnownHostsPath || stateHelper.SshKnownHostsPath if (knownHostsPath) { try { + core.info(`Removing SSH known hosts '${knownHostsPath}'`) await io.rmRF(knownHostsPath) - } catch { - // Intentionally empty + } catch (err) { + core.debug(`${(err as any)?.message ?? err}`) + core.warning(`Failed to remove SSH known hosts '${knownHostsPath}'`) } } // SSH command + core.info('Removing SSH command configuration') await this.removeGitConfig(SSH_COMMAND_KEY) + await this.removeSubmoduleGitConfig(SSH_COMMAND_KEY) } + /** + * Removes token-based authentication by cleaning up HTTP headers, + * includeIf entries, and credentials config files. + */ private async removeToken(): Promise { - // HTTP extra header + // Remove HTTP extra header + core.info('Removing HTTP extra header') await this.removeGitConfig(this.tokenConfigKey) - } + await this.removeSubmoduleGitConfig(this.tokenConfigKey) - private async removeGitConfig( - configKey: string, - submoduleOnly: boolean = false - ): Promise { - if (!submoduleOnly) { - if ( - (await this.git.configExists(configKey)) && - !(await this.git.tryConfigUnset(configKey)) - ) { - // Load the config contents - core.warning(`Failed to remove '${configKey}' from the git config`) - } + // Collect credentials config paths that need to be removed + const credentialsPaths = new Set() + + // Remove includeIf entries that point to git-credentials-*.config files + core.info('Removing includeIf entries pointing to credentials config files') + const mainCredentialsPaths = await this.removeIncludeIfCredentials() + mainCredentialsPaths.forEach(path => credentialsPaths.add(path)) + + // Remove submodule includeIf entries that point to git-credentials-*.config files + const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true) + for (const configPath of submoduleConfigPaths) { + const submoduleCredentialsPaths = + await this.removeIncludeIfCredentials(configPath) + submoduleCredentialsPaths.forEach(path => credentialsPaths.add(path)) } + // Remove credentials config files + for (const credentialsPath of credentialsPaths) { + // Only remove credentials config files if they are under RUNNER_TEMP + const runnerTemp = process.env['RUNNER_TEMP'] + assert.ok(runnerTemp, 'RUNNER_TEMP is not defined') + if (credentialsPath.startsWith(runnerTemp)) { + try { + core.info(`Removing credentials config '${credentialsPath}'`) + await io.rmRF(credentialsPath) + } catch (err) { + core.debug(`${(err as any)?.message ?? err}`) + core.warning( + `Failed to remove credentials config '${credentialsPath}'` + ) + } + } else { + core.debug( + `Skipping removal of credentials config '${credentialsPath}' - not under RUNNER_TEMP` + ) + } + } + } + + /** + * Removes a git config key from the local repository config. + * @param configKey The git config key to remove + */ + private async removeGitConfig(configKey: string): Promise { + if ( + (await this.git.configExists(configKey)) && + !(await this.git.tryConfigUnset(configKey)) + ) { + // Load the config contents + core.warning(`Failed to remove '${configKey}' from the git config`) + } + } + + /** + * Removes a git config key from all submodule configs. + * @param configKey The git config key to remove + */ + private async removeSubmoduleGitConfig(configKey: string): Promise { const pattern = regexpHelper.escape(configKey) await this.git.submoduleForeach( - // wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline + // Wrap the pipeline in quotes to make sure it's handled properly by submoduleForeach, rather than just the first part of the pipeline. `sh -c "git config --local --name-only --get-regexp '${pattern}' && git config --local --unset-all '${configKey}' || :"`, true ) } + + /** + * Removes includeIf entries that point to git-credentials-*.config files. + * @param configPath Optional path to a specific git config file to operate on + * @returns Array of unique credentials config file paths that were found and removed + */ + private async removeIncludeIfCredentials( + configPath?: string + ): Promise { + const credentialsPaths = new Set() + + try { + // Get all includeIf.gitdir keys + const keys = await this.git.tryGetConfigKeys( + '^includeIf\\.gitdir:', + false, // globalConfig? + configPath + ) + + for (const key of keys) { + // Get all values for this key + const values = await this.git.tryGetConfigValues( + key, + false, // globalConfig? + configPath + ) + if (values.length > 0) { + // Remove only values that match git-credentials-.config pattern + for (const value of values) { + if (this.testCredentialsConfigPath(value)) { + credentialsPaths.add(value) + await this.git.tryConfigUnsetValue(key, value, false, configPath) + } + } + } + } + } catch (err) { + // Ignore errors - this is cleanup code + if (configPath) { + core.debug(`Error during includeIf cleanup for ${configPath}: ${err}`) + } else { + core.debug(`Error during includeIf cleanup: ${err}`) + } + } + + return Array.from(credentialsPaths) + } + + /** + * Tests if a path matches the git-credentials-*.config pattern. + * @param path The path to test + * @returns True if the path matches the credentials config pattern + */ + private testCredentialsConfigPath(path: string): boolean { + return /git-credentials-[0-9a-f-]+\.config$/i.test(path) + } } diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 8e42a38..a45e15a 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -28,7 +28,8 @@ export interface IGitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean + add?: boolean, + configFile?: string ): Promise configExists(configKey: string, globalConfig?: boolean): Promise fetch( @@ -41,6 +42,7 @@ export interface IGitCommandManager { } ): Promise getDefaultBranch(repositoryUrl: string): Promise + getSubmoduleConfigPaths(recursive: boolean): Promise getWorkingDirectory(): string init(): Promise isDetached(): Promise @@ -59,8 +61,24 @@ export interface IGitCommandManager { tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string, globalConfig?: boolean): Promise + tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean, + configFile?: string + ): Promise tryDisableAutomaticGarbageCollection(): Promise tryGetFetchUrl(): Promise + tryGetConfigValues( + configKey: string, + globalConfig?: boolean, + configFile?: string + ): Promise + tryGetConfigKeys( + pattern: string, + globalConfig?: boolean, + configFile?: string + ): Promise tryReset(): Promise version(): Promise } @@ -223,9 +241,15 @@ class GitCommandManager { configKey: string, configValue: string, globalConfig?: boolean, - add?: boolean + add?: boolean, + configFile?: string ): Promise { - const args: string[] = ['config', globalConfig ? '--global' : '--local'] + const args: string[] = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } if (add) { args.push('--add') } @@ -323,6 +347,21 @@ class GitCommandManager { throw new Error('Unexpected output when retrieving default branch') } + async getSubmoduleConfigPaths(recursive: boolean): Promise { + // Get submodule config file paths. + // Use `--show-origin` to get the config file path for each submodule. + const output = await this.submoduleForeach( + `git config --local --show-origin --name-only --get-regexp remote.origin.url`, + recursive + ) + + // Extract config file paths from the output (lines starting with "file:"). + const configPaths = + output.match(/(?<=(^|\n)file:)[^\t]+(?=\tremote\.origin\.url)/g) || [] + + return configPaths + } + getWorkingDirectory(): string { return this.workingDirectory } @@ -455,6 +494,24 @@ class GitCommandManager { return output.exitCode === 0 } + async tryConfigUnsetValue( + configKey: string, + configValue: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--unset', configKey, configValue) + + const output = await this.execGit(args, true) + return output.exitCode === 0 + } + async tryDisableAutomaticGarbageCollection(): Promise { const output = await this.execGit( ['config', '--local', 'gc.auto', '0'], @@ -481,6 +538,56 @@ class GitCommandManager { return stdout } + async tryGetConfigValues( + configKey: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--get-all', configKey) + + const output = await this.execGit(args, true) + + if (output.exitCode !== 0) { + return [] + } + + return output.stdout + .trim() + .split('\n') + .filter(value => value.trim()) + } + + async tryGetConfigKeys( + pattern: string, + globalConfig?: boolean, + configFile?: string + ): Promise { + const args = ['config'] + if (configFile) { + args.push('--file', configFile) + } else { + args.push(globalConfig ? '--global' : '--local') + } + args.push('--name-only', '--get-regexp', pattern) + + const output = await this.execGit(args, true) + + if (output.exitCode !== 0) { + return [] + } + + return output.stdout + .trim() + .split('\n') + .filter(key => key.trim()) + } + async tryReset(): Promise { const output = await this.execGit(['reset', '--hard', 'HEAD'], true) return output.exitCode === 0