This commit is contained in:
eric sciple
2025-10-17 00:02:33 +00:00
parent 3292e202f3
commit f8060825ea
5 changed files with 320 additions and 194 deletions

View File

@@ -44,7 +44,6 @@ class GitAuthHelper {
private sshKnownHostsPath = ''
private temporaryHomePath = ''
private credentialsConfigPath = '' // Path to separate credentials config file in RUNNER_TEMP
private credentialsIncludeKeys: string[] = [] // Track includeIf config keys for cleanup
constructor(
gitCommandManager: IGitCommandManager,
@@ -83,22 +82,6 @@ class GitAuthHelper {
await this.configureToken()
}
private async getCredentialsConfigPath(): Promise<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
}
async configureTempGlobalConfig(): Promise<string> {
// Already setup global config
if (this.temporaryHomePath?.length > 0) {
@@ -192,16 +175,10 @@ class GitAuthHelper {
)
// Get submodule config file paths.
// Use `--show-origin` to get the config file path for each submodule.
const output = await this.git.submoduleForeach(
`git config --local --show-origin --name-only --get-regexp remote.origin.url`,
const configPaths = await this.git.getSubmoduleConfigPaths(
this.settings.nestedSubmodules
)
// Extract config file paths from the output (lines starting with "file:").
const configPaths =
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) {
@@ -268,6 +245,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<void> {
if (!this.settings.sshKey) {
return
@@ -339,6 +320,11 @@ class GitAuthHelper {
}
}
/**
* 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<void> {
// Get the credentials config file path in RUNNER_TEMP
const credentialsConfigPath = await this.getCredentialsConfigPath()
@@ -356,7 +342,20 @@ class GitAuthHelper {
)
// Replace the placeholder in the credentials config file
await this.replaceTokenPlaceholder(credentialsConfigPath)
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 ${credentialsConfigPath}`)
}
assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
content = content.replace(
this.tokenPlaceholderConfigValue,
this.tokenConfigValue
)
await fs.promises.writeFile(credentialsConfigPath, content)
// Add include or includeIf to reference the credentials config
if (globalConfig) {
@@ -370,7 +369,6 @@ class GitAuthHelper {
// Configure host includeIf
const hostIncludeKey = `includeIf.gitdir:${gitDir}.path`
await this.git.config(hostIncludeKey, credentialsConfigPath)
this.credentialsIncludeKeys.push(hostIncludeKey)
// Container git directory
const githubWorkspace = process.env['GITHUB_WORKSPACE']
@@ -393,28 +391,33 @@ class GitAuthHelper {
// Configure container includeIf
const containerIncludeKey = `includeIf.gitdir:${containerGitDir}.path`
await this.git.config(containerIncludeKey, containerCredentialsPath)
this.credentialsIncludeKeys.push(containerIncludeKey)
}
}
private async replaceTokenPlaceholder(configPath: string): Promise<void> {
assert.ok(configPath, 'configPath is not defined')
let content = (await fs.promises.readFile(configPath)).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}`)
/**
* Gets or creates the path to the credentials config file in RUNNER_TEMP.
* @returns The absolute path to the credentials config file
*/
private async getCredentialsConfigPath(): Promise<string> {
if (this.credentialsConfigPath) {
return this.credentialsConfigPath
}
assert.ok(this.tokenConfigValue, 'tokenConfigValue is not defined')
content = content.replace(
this.tokenPlaceholderConfigValue,
this.tokenConfigValue
)
await fs.promises.writeFile(configPath, content)
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<void> {
// SSH key
const keyPath = this.sshKeyPath || stateHelper.SshKeyPath
@@ -443,40 +446,23 @@ class GitAuthHelper {
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<void> {
// Remove HTTP extra header
await this.removeGitConfig(this.tokenConfigKey)
await this.removeSubmoduleGitConfig(this.tokenConfigKey)
// Remove includeIf entries that point to git-credentials-*.config files
// This is more aggressive than tracking keys, but necessary since cleanup
// runs in a post-step where this.credentialsIncludeKeys is empty
try {
// Get all includeIf.gitdir keys
const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:')
for (const key of keys) {
// Get all values for this key
const values = await this.git.tryGetConfigValues(key)
if (values.length > 0) {
// Remove only values that match git-credentials-<uuid>.config pattern
for (const value of values) {
if (/git-credentials-[0-9a-f-]+\.config$/i.test(value)) {
await this.git.tryConfigUnsetValue(key, value)
}
}
}
}
} catch (err) {
// Ignore errors - this is cleanup code
core.debug(`Error during includeIf cleanup: ${err}`)
}
await this.removeIncludeIfCredentials()
// Remove submodule includeIf
await this.git.submoduleForeach(
`sh -c "git config --local --get-regexp '^includeif\\.' && git config --local --remove-section includeif || :"`,
true
)
// Remove submodule includeIf entries that point to git-credentials-*.config files
const submoduleConfigPaths = await this.git.getSubmoduleConfigPaths(true)
for (const configPath of submoduleConfigPaths) {
await this.removeIncludeIfCredentials(configPath)
}
// Remove credentials config file
if (this.credentialsConfigPath) {
@@ -491,6 +477,10 @@ class GitAuthHelper {
}
}
/**
* Removes a git config key from the local repository config.
* @param configKey The git config key to remove
*/
private async removeGitConfig(configKey: string): Promise<void> {
if (
(await this.git.configExists(configKey)) &&
@@ -501,6 +491,10 @@ class GitAuthHelper {
}
}
/**
* Removes a git config key from all submodule configs.
* @param configKey The git config key to remove
*/
private async removeSubmoduleGitConfig(configKey: string): Promise<void> {
const pattern = regexpHelper.escape(configKey)
await this.git.submoduleForeach(
@@ -509,4 +503,44 @@ class GitAuthHelper {
true
)
}
/**
* Removes includeIf entries that point to git-credentials-*.config files.
* @param configPath Optional path to a specific git config file to operate on
*/
private async removeIncludeIfCredentials(configPath?: string): Promise<void> {
try {
// Get all includeIf.gitdir keys
const keys = await this.git.tryGetConfigKeys('^includeIf\\.gitdir:', false, configPath)
for (const key of keys) {
// Get all values for this key
const values = await this.git.tryGetConfigValues(key, false, configPath)
if (values.length > 0) {
// Remove only values that match git-credentials-<uuid>.config pattern
for (const value of values) {
if (this.testCredentialsConfigPath(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}`)
}
}
}
/**
* 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)
}
}

View File

@@ -42,6 +42,7 @@ export interface IGitCommandManager {
}
): Promise<void>
getDefaultBranch(repositoryUrl: string): Promise<string>
getSubmoduleConfigPaths(recursive: boolean): Promise<string[]>
getWorkingDirectory(): string
init(): Promise<void>
isDetached(): Promise<boolean>
@@ -60,11 +61,11 @@ export interface IGitCommandManager {
tagExists(pattern: string): Promise<boolean>
tryClean(): Promise<boolean>
tryConfigUnset(configKey: string, globalConfig?: boolean): Promise<boolean>
tryConfigUnsetValue(configKey: string, configValue: string, globalConfig?: boolean): Promise<boolean>
tryConfigUnsetValue(configKey: string, configValue: string, globalConfig?: boolean, configFile?: string): Promise<boolean>
tryDisableAutomaticGarbageCollection(): Promise<boolean>
tryGetFetchUrl(): Promise<string>
tryGetConfigValues(configKey: string, globalConfig?: boolean): Promise<string[]>
tryGetConfigKeys(pattern: string, globalConfig?: boolean): Promise<string[]>
tryGetConfigValues(configKey: string, globalConfig?: boolean, configFile?: string): Promise<string[]>
tryGetConfigKeys(pattern: string, globalConfig?: boolean, configFile?: string): Promise<string[]>
tryReset(): Promise<boolean>
version(): Promise<GitVersion>
}
@@ -333,6 +334,21 @@ class GitCommandManager {
throw new Error('Unexpected output when retrieving default branch')
}
async getSubmoduleConfigPaths(recursive: boolean): Promise<string[]> {
// 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
}
@@ -468,18 +484,18 @@ class GitCommandManager {
async tryConfigUnsetValue(
configKey: string,
configValue: string,
globalConfig?: boolean
globalConfig?: boolean,
configFile?: string
): Promise<boolean> {
const output = await this.execGit(
[
'config',
globalConfig ? '--global' : '--local',
'--unset',
configKey,
configValue
],
true
)
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
}
@@ -511,17 +527,18 @@ class GitCommandManager {
async tryGetConfigValues(
configKey: string,
globalConfig?: boolean
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const output = await this.execGit(
[
'config',
globalConfig ? '--global' : '--local',
'--get-all',
configKey
],
true
)
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 []
@@ -532,18 +549,18 @@ class GitCommandManager {
async tryGetConfigKeys(
pattern: string,
globalConfig?: boolean
globalConfig?: boolean,
configFile?: string
): Promise<string[]> {
const output = await this.execGit(
[
'config',
globalConfig ? '--global' : '--local',
'--name-only',
'--get-regexp',
pattern
],
true
)
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 []