From 684bbf983233d1341d533f6fe471109f385c631a Mon Sep 17 00:00:00 2001 From: eric sciple Date: Mon, 2 Mar 2020 11:33:30 -0500 Subject: more unit tests and corresponding refactoring (#174) --- src/git-auth-helper.ts | 102 ++++++++++++++++++++++++ src/git-command-manager.ts | 7 +- src/git-directory-helper.ts | 91 ++++++++++++++++++++++ src/git-source-provider.ts | 185 +++++--------------------------------------- src/git-source-settings.ts | 12 +++ src/input-helper.ts | 6 +- src/state-helper.ts | 1 - 7 files changed, 234 insertions(+), 170 deletions(-) create mode 100644 src/git-auth-helper.ts create mode 100644 src/git-directory-helper.ts create mode 100644 src/git-source-settings.ts (limited to 'src') diff --git a/src/git-auth-helper.ts b/src/git-auth-helper.ts new file mode 100644 index 0000000..7f7f11f --- /dev/null +++ b/src/git-auth-helper.ts @@ -0,0 +1,102 @@ +import * as assert from 'assert' +import * as core from '@actions/core' +import * as exec from '@actions/exec' +import * as fs from 'fs' +import * as io from '@actions/io' +import * as os from 'os' +import * as path from 'path' +import * as stateHelper from './state-helper' +import {default as uuid} from 'uuid/v4' +import {IGitCommandManager} from './git-command-manager' +import {IGitSourceSettings} from './git-source-settings' + +const IS_WINDOWS = process.platform === 'win32' +const HOSTNAME = 'github.com' +const EXTRA_HEADER_KEY = `http.https://${HOSTNAME}/.extraheader` + +export interface IGitAuthHelper { + configureAuth(): Promise + removeAuth(): Promise +} + +export function createAuthHelper( + git: IGitCommandManager, + settings?: IGitSourceSettings +): IGitAuthHelper { + return new GitAuthHelper(git, settings) +} + +class GitAuthHelper { + private git: IGitCommandManager + private settings: IGitSourceSettings + + constructor( + gitCommandManager: IGitCommandManager, + gitSourceSettings?: IGitSourceSettings + ) { + this.git = gitCommandManager + this.settings = gitSourceSettings || (({} as unknown) as IGitSourceSettings) + } + + async configureAuth(): Promise { + // Remove possible previous values + await this.removeAuth() + + // Configure new values + await this.configureToken() + } + + async removeAuth(): Promise { + await this.removeToken() + } + + private async configureToken(): Promise { + // 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 placeholder = `AUTHORIZATION: basic ***` + await this.git.config(EXTRA_HEADER_KEY, placeholder) + + // Determine the basic credential value + const basicCredential = Buffer.from( + `x-access-token:${this.settings.authToken}`, + 'utf8' + ).toString('base64') + core.setSecret(basicCredential) + + // Replace the value in the config file + const configPath = path.join( + this.git.getWorkingDirectory(), + '.git', + 'config' + ) + let content = (await fs.promises.readFile(configPath)).toString() + const placeholderIndex = content.indexOf(placeholder) + if ( + placeholderIndex < 0 || + placeholderIndex != content.lastIndexOf(placeholder) + ) { + throw new Error('Unable to replace auth placeholder in .git/config') + } + content = content.replace( + placeholder, + `AUTHORIZATION: basic ${basicCredential}` + ) + await fs.promises.writeFile(configPath, content) + } + + private async removeToken(): Promise { + // HTTP extra header + await this.removeGitConfig(EXTRA_HEADER_KEY) + } + + 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`) + } + } +} diff --git a/src/git-command-manager.ts b/src/git-command-manager.ts index 74489c2..2b0d054 100644 --- a/src/git-command-manager.ts +++ b/src/git-command-manager.ts @@ -26,6 +26,7 @@ export interface IGitCommandManager { lfsInstall(): Promise log1(): Promise remoteAdd(remoteName: string, remoteUrl: string): Promise + setEnvironmentVariable(name: string, value: string): void tagExists(pattern: string): Promise tryClean(): Promise tryConfigUnset(configKey: string): Promise @@ -34,7 +35,7 @@ export interface IGitCommandManager { tryReset(): Promise } -export async function CreateCommandManager( +export async function createCommandManager( workingDirectory: string, lfs: boolean ): Promise { @@ -207,6 +208,10 @@ class GitCommandManager { await this.execGit(['remote', 'add', remoteName, remoteUrl]) } + setEnvironmentVariable(name: string, value: string): void { + this.gitEnv[name] = value + } + async tagExists(pattern: string): Promise { const output = await this.execGit(['tag', '--list', pattern]) return !!output.stdout.trim() diff --git a/src/git-directory-helper.ts b/src/git-directory-helper.ts new file mode 100644 index 0000000..bfaa4a9 --- /dev/null +++ b/src/git-directory-helper.ts @@ -0,0 +1,91 @@ +import * as core from '@actions/core' +import * as fs from 'fs' +import * as fsHelper from './fs-helper' +import * as io from '@actions/io' +import * as path from 'path' +import {IGitCommandManager} from './git-command-manager' + +export async function prepareExistingDirectory( + git: IGitCommandManager | undefined, + repositoryPath: string, + repositoryUrl: string, + clean: boolean +): Promise { + let remove = false + + // Check whether using git or REST API + if (!git) { + remove = true + } + // Fetch URL does not match + else if ( + !fsHelper.directoryExistsSync(path.join(repositoryPath, '.git')) || + repositoryUrl !== (await git.tryGetFetchUrl()) + ) { + remove = true + } else { + // Delete any index.lock and shallow.lock left by a previously canceled run or crashed git process + const lockPaths = [ + path.join(repositoryPath, '.git', 'index.lock'), + path.join(repositoryPath, '.git', 'shallow.lock') + ] + for (const lockPath of lockPaths) { + try { + await io.rmRF(lockPath) + } catch (error) { + core.debug(`Unable to delete '${lockPath}'. ${error.message}`) + } + } + + try { + // Checkout detached HEAD + if (!(await git.isDetached())) { + await git.checkoutDetach() + } + + // Remove all refs/heads/* + let branches = await git.branchList(false) + for (const branch of branches) { + await git.branchDelete(false, branch) + } + + // Remove all refs/remotes/origin/* to avoid conflicts + branches = await git.branchList(true) + for (const branch of branches) { + await git.branchDelete(true, branch) + } + + // Clean + if (clean) { + if (!(await git.tryClean())) { + core.debug( + `The clean command failed. This might be caused by: 1) path too long, 2) permission issue, or 3) file in use. For futher investigation, manually run 'git clean -ffdx' on the directory '${repositoryPath}'.` + ) + remove = true + } else if (!(await git.tryReset())) { + remove = true + } + + if (remove) { + core.warning( + `Unable to clean or reset the repository. The repository will be recreated instead.` + ) + } + } + } catch (error) { + core.warning( + `Unable to prepare the existing repository. The repository will be recreated instead.` + ) + remove = true + } + } + + if (remove) { + // Delete the contents of the directory. Don't delete the directory itself + // since it might be the current working directory. + core.info(`Deleting the contents of '${repositoryPath}'`) + for (const file of await fs.promises.readdir(repositoryPath)) { + await io.rmRF(path.join(repositoryPath, file)) + } + } +} diff --git a/src/git-source-provider.ts b/src/git-source-provider.ts index e0dd58e..34b822c 100644 --- a/src/git-source-provider.ts +++ b/src/git-source-provider.ts @@ -1,36 +1,24 @@ import * as core from '@actions/core' -import * as fs from 'fs' import * as fsHelper from './fs-helper' +import * as gitAuthHelper from './git-auth-helper' import * as gitCommandManager from './git-command-manager' +import * as gitDirectoryHelper from './git-directory-helper' import * as githubApiHelper from './github-api-helper' import * as io from '@actions/io' import * as path from 'path' import * as refHelper from './ref-helper' import * as stateHelper from './state-helper' import {IGitCommandManager} from './git-command-manager' +import {IGitSourceSettings} from './git-source-settings' -const serverUrl = 'https://github.com/' -const authConfigKey = `http.${serverUrl}.extraheader` +const hostname = 'github.com' -export interface ISourceSettings { - repositoryPath: string - repositoryOwner: string - repositoryName: string - ref: string - commit: string - clean: boolean - fetchDepth: number - lfs: boolean - authToken: string - persistCredentials: boolean -} - -export async function getSource(settings: ISourceSettings): Promise { +export async function getSource(settings: IGitSourceSettings): Promise { // Repository URL core.info( `Syncing repository: ${settings.repositoryOwner}/${settings.repositoryName}` ) - const repositoryUrl = `https://github.com/${encodeURIComponent( + const repositoryUrl = `https://${hostname}/${encodeURIComponent( settings.repositoryOwner )}/${encodeURIComponent(settings.repositoryName)}` @@ -51,7 +39,7 @@ export async function getSource(settings: ISourceSettings): Promise { // Prepare existing directory, otherwise recreate if (isExisting) { - await prepareExistingDirectory( + await gitDirectoryHelper.prepareExistingDirectory( git, settings.repositoryPath, repositoryUrl, @@ -92,12 +80,10 @@ export async function getSource(settings: ISourceSettings): Promise { ) } - // Remove possible previous extraheader - await removeGitConfig(git, authConfigKey) - + const authHelper = gitAuthHelper.createAuthHelper(git, settings) try { - // Config extraheader - await configureAuthToken(git, settings.authToken) + // Configure auth + await authHelper.configureAuth() // LFS install if (settings.lfs) { @@ -128,8 +114,9 @@ export async function getSource(settings: ISourceSettings): Promise { // Dump some info about the checked out commit await git.log1() } finally { + // Remove auth if (!settings.persistCredentials) { - await removeGitConfig(git, authConfigKey) + await authHelper.removeAuth() } } } @@ -146,22 +133,22 @@ export async function cleanup(repositoryPath: string): Promise { let git: IGitCommandManager try { - git = await gitCommandManager.CreateCommandManager(repositoryPath, false) + git = await gitCommandManager.createCommandManager(repositoryPath, false) } catch { return } - // Remove extraheader - await removeGitConfig(git, authConfigKey) + // Remove auth + const authHelper = gitAuthHelper.createAuthHelper(git) + await authHelper.removeAuth() } async function getGitCommandManager( - settings: ISourceSettings -): Promise { + settings: IGitSourceSettings +): Promise { core.info(`Working directory is '${settings.repositoryPath}'`) - let git = (null as unknown) as IGitCommandManager try { - return await gitCommandManager.CreateCommandManager( + return await gitCommandManager.createCommandManager( settings.repositoryPath, settings.lfs ) @@ -172,138 +159,6 @@ async function getGitCommandManager( } // Otherwise fallback to REST API - return (null as unknown) as IGitCommandManager - } -} - -async function prepareExistingDirectory( - git: IGitCommandManager, - repositoryPath: string, - repositoryUrl: string, - clean: boolean -): Promise { - let remove = false - - // Check whether using git or REST API - if (!git) { - remove = true - } - // Fetch URL does not match - else if ( - !fsHelper.directoryExistsSync(path.join(repositoryPath, '.git')) || - repositoryUrl !== (await git.tryGetFetchUrl()) - ) { - remove = true - } else { - // Delete any index.lock and shallow.lock left by a previously canceled run or crashed git process - const lockPaths = [ - path.join(repositoryPath, '.git', 'index.lock'), - path.join(repositoryPath, '.git', 'shallow.lock') - ] - for (const lockPath of lockPaths) { - try { - await io.rmRF(lockPath) - } catch (error) { - core.debug(`Unable to delete '${lockPath}'. ${error.message}`) - } - } - - try { - // Checkout detached HEAD - if (!(await git.isDetached())) { - await git.checkoutDetach() - } - - // Remove all refs/heads/* - let branches = await git.branchList(false) - for (const branch of branches) { - await git.branchDelete(false, branch) - } - - // Remove all refs/remotes/origin/* to avoid conflicts - branches = await git.branchList(true) - for (const branch of branches) { - await git.branchDelete(true, branch) - } - - // Clean - if (clean) { - if (!(await git.tryClean())) { - core.debug( - `The clean command failed. This might be caused by: 1) path too long, 2) permission issue, or 3) file in use. For futher investigation, manually run 'git clean -ffdx' on the directory '${repositoryPath}'.` - ) - remove = true - } else if (!(await git.tryReset())) { - remove = true - } - - if (remove) { - core.warning( - `Unable to clean or reset the repository. The repository will be recreated instead.` - ) - } - } - } catch (error) { - core.warning( - `Unable to prepare the existing repository. The repository will be recreated instead.` - ) - remove = true - } - } - - if (remove) { - // Delete the contents of the directory. Don't delete the directory itself - // since it might be the current working directory. - core.info(`Deleting the contents of '${repositoryPath}'`) - for (const file of await fs.promises.readdir(repositoryPath)) { - await io.rmRF(path.join(repositoryPath, file)) - } - } -} - -async function configureAuthToken( - git: IGitCommandManager, - authToken: string -): Promise { - // 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 placeholder = `AUTHORIZATION: basic ***` - await git.config(authConfigKey, placeholder) - - // Determine the basic credential value - const basicCredential = Buffer.from( - `x-access-token:${authToken}`, - 'utf8' - ).toString('base64') - core.setSecret(basicCredential) - - // Replace the value in the config file - const configPath = path.join(git.getWorkingDirectory(), '.git', 'config') - let content = (await fs.promises.readFile(configPath)).toString() - const placeholderIndex = content.indexOf(placeholder) - if ( - placeholderIndex < 0 || - placeholderIndex != content.lastIndexOf(placeholder) - ) { - throw new Error('Unable to replace auth placeholder in .git/config') - } - content = content.replace( - placeholder, - `AUTHORIZATION: basic ${basicCredential}` - ) - await fs.promises.writeFile(configPath, content) -} - -async function removeGitConfig( - git: IGitCommandManager, - configKey: string -): Promise { - if ( - (await git.configExists(configKey)) && - !(await git.tryConfigUnset(configKey)) - ) { - // Load the config contents - core.warning(`Failed to remove '${configKey}' from the git config`) + return undefined } } diff --git a/src/git-source-settings.ts b/src/git-source-settings.ts new file mode 100644 index 0000000..49d8825 --- /dev/null +++ b/src/git-source-settings.ts @@ -0,0 +1,12 @@ +export interface IGitSourceSettings { + repositoryPath: string + repositoryOwner: string + repositoryName: string + ref: string + commit: string + clean: boolean + fetchDepth: number + lfs: boolean + authToken: string + persistCredentials: boolean +} diff --git a/src/input-helper.ts b/src/input-helper.ts index 54339da..e50dbc5 100644 --- a/src/input-helper.ts +++ b/src/input-helper.ts @@ -2,10 +2,10 @@ import * as core from '@actions/core' import * as fsHelper from './fs-helper' import * as github from '@actions/github' import * as path from 'path' -import {ISourceSettings} from './git-source-provider' +import {IGitSourceSettings} from './git-source-settings' -export function getInputs(): ISourceSettings { - const result = ({} as unknown) as ISourceSettings +export function getInputs(): IGitSourceSettings { + const result = ({} as unknown) as IGitSourceSettings // GitHub workspace let githubWorkspacePath = process.env['GITHUB_WORKSPACE'] diff --git a/src/state-helper.ts b/src/state-helper.ts index 0f86a14..da15d86 100644 --- a/src/state-helper.ts +++ b/src/state-helper.ts @@ -1,4 +1,3 @@ -import * as core from '@actions/core' import * as coreCommand from '@actions/core/lib/command' /** -- cgit v1.2.3