diff --git a/src/dataSource.ts b/src/dataSource.ts index 92bd6b6e..7e210db2 100644 --- a/src/dataSource.ts +++ b/src/dataSource.ts @@ -6,7 +6,7 @@ import * as vscode from 'vscode'; import { AskpassEnvironment, AskpassManager } from './askpass/askpassManager'; import { getConfig } from './config'; import { Logger } from './logger'; -import { CommitOrdering, DateType, DeepWriteable, ErrorInfo, GitCommit, GitCommitDetails, GitCommitStash, GitConfigLocation, GitFileChange, GitFileStatus, GitPushBranchMode, GitRepoConfig, GitRepoConfigBranches, GitResetMode, GitSignatureStatus, GitStash, MergeActionOn, RebaseActionOn, SquashMessageFormat, TagType, Writeable } from './types'; +import { CommitOrdering, DateType, DeepWriteable, ErrorInfo, GitCommit, GitCommitDetails, GitCommitStash, GitConfigLocation, GitFileChange, GitFileStatus, GitPushBranchMode, GitRepoConfig, GitRepoConfigBranches, GitResetMode, GitSignature, GitSignatureStatus, GitStash, GitTagDetails, MergeActionOn, RebaseActionOn, SquashMessageFormat, TagType, Writeable } from './types'; import { GitExecutable, UNABLE_TO_FIND_GIT_MSG, UNCOMMITTED, abbrevCommit, constructIncompatibleGitVersionMessage, doesVersionMeetRequirement, getPathFromStr, getPathFromUri, openGitTerminal, pathWithTrailingSlash, realpath, resolveSpawnOutput, showErrorMessage } from './utils'; import { Disposable } from './utils/disposable'; import { Event } from './utils/event'; @@ -31,6 +31,15 @@ export const GIT_CONFIG = { } }; +const GPG_STATUS_CODE_PARSING_DETAILS: { [statusCode: string]: GpgStatusCodeParsingDetails } = { + 'GOODSIG': { status: GitSignatureStatus.GoodAndValid, uid: true }, + 'BADSIG': { status: GitSignatureStatus.Bad, uid: true }, + 'ERRSIG': { status: GitSignatureStatus.CannotBeChecked, uid: false }, + 'EXPSIG': { status: GitSignatureStatus.GoodButExpired, uid: true }, + 'EXPKEYSIG': { status: GitSignatureStatus.GoodButMadeByExpiredKey, uid: true }, + 'REVKEYSIG': { status: GitSignatureStatus.GoodButMadeByRevokedKey, uid: true } +}; + /** * Interfaces Git Graph with the Git executable to provide all Git integrations. */ @@ -494,21 +503,37 @@ export class DataSource extends Disposable { * @returns The tag details. */ public getTagDetails(repo: string, tagName: string): Promise { - return this.spawnGit(['for-each-ref', 'refs/tags/' + tagName, '--format=' + ['%(objectname)', '%(taggername)', '%(taggeremail)', '%(taggerdate:unix)', '%(contents)'].join(GIT_LOG_SEPARATOR)], repo, (stdout) => { - let data = stdout.split(GIT_LOG_SEPARATOR); + if (this.gitExecutable !== null && !doesVersionMeetRequirement(this.gitExecutable.version, '1.7.8')) { + return Promise.resolve({ details: null, error: constructIncompatibleGitVersionMessage(this.gitExecutable, '1.7.8', 'retrieving Tag Details') }); + } + + const ref = 'refs/tags/' + tagName; + return this.spawnGit(['for-each-ref', ref, '--format=' + ['%(objectname)', '%(taggername)', '%(taggeremail)', '%(taggerdate:unix)', '%(contents:signature)', '%(contents)'].join(GIT_LOG_SEPARATOR)], repo, (stdout) => { + const data = stdout.split(GIT_LOG_SEPARATOR); return { - tagHash: data[0], - name: data[1], - email: data[2].substring(data[2].startsWith('<') ? 1 : 0, data[2].length - (data[2].endsWith('>') ? 1 : 0)), - date: parseInt(data[3]), - message: removeTrailingBlankLines(data[4].split(EOL_REGEX)).join('\n'), - error: null + hash: data[0], + taggerName: data[1], + taggerEmail: data[2].substring(data[2].startsWith('<') ? 1 : 0, data[2].length - (data[2].endsWith('>') ? 1 : 0)), + taggerDate: parseInt(data[3]), + message: removeTrailingBlankLines(data.slice(5).join(GIT_LOG_SEPARATOR).replace(data[4], '').split(EOL_REGEX)).join('\n'), + signed: data[4] !== '' }; - }).then((data) => { - return data; - }).catch((errorMessage) => { - return { tagHash: '', name: '', email: '', date: 0, message: '', error: errorMessage }; - }); + }).then(async (tag) => ({ + details: { + hash: tag.hash, + taggerName: tag.taggerName, + taggerEmail: tag.taggerEmail, + taggerDate: tag.taggerDate, + message: tag.message, + signature: tag.signed + ? await this.getTagSignature(repo, ref) + : null + }, + error: null + })).catch((errorMessage) => ({ + details: null, + error: errorMessage + })); } /** @@ -1595,6 +1620,52 @@ export class DataSource extends Disposable { }); } + /** + * Get the signature of a signed tag. + * @param repo The path of the repository. + * @param ref The reference identifying the tag. + * @returns A Promise resolving to the signature. + */ + private getTagSignature(repo: string, ref: string): Promise { + return this._spawnGit(['verify-tag', '--raw', ref], repo, (stdout, stderr) => stderr || stdout.toString(), true).then((output) => { + const records = output.split(EOL_REGEX) + .filter((line) => line.startsWith('[GNUPG:] ')) + .map((line) => line.split(' ')); + + let signature: Writeable | null = null, trustLevel: string | null = null, parsingDetails: GpgStatusCodeParsingDetails | undefined; + for (let i = 0; i < records.length; i++) { + parsingDetails = GPG_STATUS_CODE_PARSING_DETAILS[records[i][1]]; + if (parsingDetails) { + if (signature !== null) { + throw new Error('Multiple Signatures Exist: As Git currently doesn\'t support them, nor does Git Graph (for consistency).'); + } else { + signature = { + status: parsingDetails.status, + key: records[i][2], + signer: parsingDetails.uid ? records[i].slice(3).join(' ') : '' // When parsingDetails.uid === TRUE, the signer is the rest of the record (so join the remaining arguments) + }; + } + } else if (records[i][1].startsWith('TRUST_')) { + trustLevel = records[i][1]; + } + } + + if (signature !== null && signature.status === GitSignatureStatus.GoodAndValid && (trustLevel === 'TRUST_UNDEFINED' || trustLevel === 'TRUST_NEVER')) { + signature.status = GitSignatureStatus.GoodWithUnknownValidity; + } + + if (signature !== null) { + return signature; + } else { + throw new Error('No Signature could be parsed.'); + } + }).catch(() => ({ + status: GitSignatureStatus.CannotBeChecked, + key: '', + signer: '' + })); + } + /** * Get the number of uncommitted changes in a repository. * @param repo The path of the repository. @@ -1715,21 +1786,24 @@ export class DataSource extends Disposable { * Spawn Git, with the return value resolved from `stdout` as a buffer. * @param args The arguments to pass to Git. * @param repo The repository to run the command in. - * @param resolveValue A callback invoked to resolve the data from `stdout`. + * @param resolveValue A callback invoked to resolve the data from `stdout` and `stderr`. + * @param ignoreExitCode Ignore the exit code returned by Git (default: `FALSE`). */ - private _spawnGit(args: string[], repo: string, resolveValue: { (stdout: Buffer): T }) { + private _spawnGit(args: string[], repo: string, resolveValue: { (stdout: Buffer, stderr: string): T }, ignoreExitCode: boolean = false) { return new Promise((resolve, reject) => { - if (this.gitExecutable === null) return reject(UNABLE_TO_FIND_GIT_MSG); + if (this.gitExecutable === null) { + return reject(UNABLE_TO_FIND_GIT_MSG); + } resolveSpawnOutput(cp.spawn(this.gitExecutable.path, args, { cwd: repo, env: Object.assign({}, process.env, this.askpassEnv) })).then((values) => { - let status = values[0], stdout = values[1]; - if (status.code === 0) { - resolve(resolveValue(stdout)); + const status = values[0], stdout = values[1], stderr = values[2]; + if (status.code === 0 || ignoreExitCode) { + resolve(resolveValue(stdout, stderr)); } else { - reject(getErrorMessage(status.error, stdout, values[2])); + reject(getErrorMessage(status.error, stdout, stderr)); } }); @@ -1915,10 +1989,11 @@ interface GitStatusFiles { } interface GitTagDetailsData { - tagHash: string; - name: string; - email: string; - date: number; - message: string; + details: GitTagDetails | null; error: ErrorInfo; } + +interface GpgStatusCodeParsingDetails { + status: GitSignatureStatus, + uid: boolean +} diff --git a/src/types.ts b/src/types.ts index 80176974..70004b0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,7 +38,7 @@ export interface GitCommitDetails { readonly committer: string; readonly committerEmail: string; readonly committerDate: number; - readonly signature: GitCommitSignature | null; + readonly signature: GitSignature | null; readonly body: string; readonly fileChanges: ReadonlyArray; } @@ -53,7 +53,7 @@ export const enum GitSignatureStatus { Bad = 'B' } -export interface GitCommitSignature { +export interface GitSignature { readonly key: string; readonly signer: string; readonly status: GitSignatureStatus; @@ -135,6 +135,15 @@ export interface GitStash { readonly message: string; } +export interface GitTagDetails { + readonly hash: string; + readonly taggerName: string; + readonly taggerEmail: string; + readonly taggerDate: number; + readonly message: string; + readonly signature: GitSignature | null; +} + /* Git Repo State */ @@ -1149,12 +1158,8 @@ export interface RequestTagDetails extends RepoRequest { export interface ResponseTagDetails extends ResponseWithErrorInfo { readonly command: 'tagDetails'; readonly tagName: string; - readonly tagHash: string; readonly commitHash: string; - readonly name: string; - readonly email: string; - readonly date: number; - readonly message: string; + readonly details: GitTagDetails | null; } export interface RequestUpdateCodeReview extends RepoRequest { diff --git a/tests/dataSource.test.ts b/tests/dataSource.test.ts index d722503a..a7316ba3 100644 --- a/tests/dataSource.test.ts +++ b/tests/dataSource.test.ts @@ -14,7 +14,7 @@ import * as path from 'path'; import { ConfigurationChangeEvent } from 'vscode'; import { DataSource } from '../src/dataSource'; import { Logger } from '../src/logger'; -import { CommitOrdering, GitConfigLocation, GitPushBranchMode, GitResetMode, MergeActionOn, RebaseActionOn, TagType } from '../src/types'; +import { CommitOrdering, GitConfigLocation, GitPushBranchMode, GitResetMode, GitSignature, GitSignatureStatus, MergeActionOn, RebaseActionOn, TagType } from '../src/types'; import * as utils from '../src/utils'; import { EventEmitter } from '../src/utils/event'; @@ -49,10 +49,15 @@ describe('DataSource', () => { dataSource.dispose(); }); - const mockGitSuccessOnce = (stdout?: string) => { + const mockGitSuccessOnce = (stdout?: string, stderr?: string) => { mockSpyOnSpawn(spyOnSpawn, (onCallbacks, stderrOnCallbacks, stdoutOnCallbacks) => { - if (stdout) stdoutOnCallbacks['data'](Buffer.from(stdout)); + if (stdout) { + stdoutOnCallbacks['data'](Buffer.from(stdout)); + } stdoutOnCallbacks['close'](); + if (stderr) { + stderrOnCallbacks['data'](Buffer.from(stderr)); + } stderrOnCallbacks['close'](); onCallbacks['exit'](0); }); @@ -3744,6 +3749,19 @@ describe('DataSource', () => { }); expect(spyOnDecode).toBeCalledWith(expect.anything(), 'utf8'); }); + + it('Should return an error message thrown by git', async () => { + // Setup + mockGitThrowingErrorOnce(); + let errorMessage = null; + + // Run + await dataSource.getCommitFile('/path/to/repo', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b', 'subdirectory/file.txt').catch((error) => errorMessage = error); + + // Assert + expect(errorMessage).toBe('error message'); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['show', '1a2b3c4d5e6f1a2b3c4d5e6f1a2b3c4d5e6f1a2b:subdirectory/file.txt'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); }); describe('getCommitSubject', () => { @@ -3835,42 +3853,89 @@ describe('DataSource', () => { }); describe('getTagDetails', () => { - it('Should return the tags details', async () => { + it('Should return the tag\'s details', async () => { + // Setup + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbsubject1\r\nsubject2\n\nbody1\nbody2\n\n'); + + // Run + const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); + + // Assert + expect(result).toStrictEqual({ + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'subject1\nsubject2\n\nbody1\nbody2', + signature: null + }, + error: null + }); + expect(spyOnSpawn).toHaveBeenCalledTimes(1); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + it('Should return the tag\'s details (when email isn\'t enclosed by <>)', async () => { // Setup - mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbtag-message\n'); + onDidChangeGitExecutable.emit({ path: '/path/to/git', version: '1.7.8' }); + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbtest@mhutchie.comXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbtag-message\n'); // Run const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); // Assert expect(result).toStrictEqual({ - tagHash: '79e88e142b378f41dfd1f82d94209a7a411384ed', - name: 'Test Tagger', - email: 'test@mhutchie.com', - date: 1587559258, - message: 'tag-message', + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'tag-message', + signature: null + }, error: null }); - expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + expect(spyOnSpawn).toHaveBeenCalledTimes(1); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); }); - it('Should return the tags details (when email isn\'t enclosed by <>)', async () => { + it('Should return the tag\'s details (contents contains separator)', async () => { // Setup - mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbtest@mhutchie.comXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbtag-message\n'); + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbsubject1\r\nsubject2\n\nbody1 XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%\nbody2\n\n'); // Run const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); // Assert expect(result).toStrictEqual({ - tagHash: '79e88e142b378f41dfd1f82d94209a7a411384ed', - name: 'Test Tagger', - email: 'test@mhutchie.com', - date: 1587559258, - message: 'tag-message', + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'subject1\nsubject2\n\nbody1 XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%\nbody2', + signature: null + }, error: null }); - expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + expect(spyOnSpawn).toHaveBeenCalledTimes(1); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + it('Should return an error message when viewing tag details with Git < 1.7.8', async () => { + // Setup + onDidChangeGitExecutable.emit({ path: '/path/to/git', version: '1.7.7' }); + + // Run + const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); + + // Assert + expect(result).toStrictEqual({ + details: null, + error: 'A newer version of Git (>= 1.7.8) is required for retrieving Tag Details. Git 1.7.7 is currently installed. Please install a newer version of Git to use this feature.' + }); + expect(spyOnSpawn).toHaveBeenCalledTimes(0); }); it('Should return an error message thrown by git', async () => { @@ -3882,13 +3947,208 @@ describe('DataSource', () => { // Assert expect(result).toStrictEqual({ - tagHash: '', - name: '', - email: '', - date: 0, - message: '', + details: null, error: 'error message' }); + expect(spyOnSpawn).toHaveBeenCalledTimes(1); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + describe('getTagSignature', () => { + const testParsingGpgStatus = (signatureRecord: string, trustLevel: string, expected: GitSignature) => async () => { + // Setup + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\nXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbsubject1\r\nsubject2\n\nbody1\nbody2\n-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n\n'); + mockGitSuccessOnce('', '[GNUPG:] NEWSIG\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] SIG_ID abcdefghijklmnopqrstuvwxyza 2021-04-10 1618040201\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n' + signatureRecord + '[GNUPG:] VALIDSIG ABCDEF1234567890ABCDEF1234567890ABCDEF12 2021-04-10 1618040201 0 4 0 1 8 00 ABCDEF1234567890ABCDEF1234567890ABCDEF12\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] ' + trustLevel + ' 0 pgp\r\n[GNUPG:] VERIFICATION_COMPLIANCE_MODE 23\r\n'); + + // Run + const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); + + // Assert + expect(result).toStrictEqual({ + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'subject1\nsubject2\n\nbody1\nbody2', + signature: expected + }, + error: null + }); + expect(spyOnSpawn).toHaveBeenCalledTimes(2); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['verify-tag', '--raw', 'refs/tags/tag-name'], expect.objectContaining({ cwd: '/path/to/repo' })); + }; + + it('Should parse and return a GOODSIG', testParsingGpgStatus('[GNUPG:] GOODSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_ULTIMATE', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodAndValid + })); + + it('Should parse and return a BADSIG', testParsingGpgStatus('[GNUPG:] BADSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_ULTIMATE', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.Bad + })); + + it('Should parse and return an ERRSIG', testParsingGpgStatus('[GNUPG:] ERRSIG 1234567890ABCDEF 0 1 2 3 4\n', 'TRUST_ULTIMATE', { + key: '1234567890ABCDEF', + signer: '', + status: GitSignatureStatus.CannotBeChecked + })); + + it('Should parse and return an EXPSIG', testParsingGpgStatus('[GNUPG:] EXPSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_ULTIMATE', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodButExpired + })); + + it('Should parse and return an EXPKEYSIG', testParsingGpgStatus('[GNUPG:] EXPKEYSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_ULTIMATE', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodButMadeByExpiredKey + })); + + it('Should parse and return a REVKEYSIG', testParsingGpgStatus('[GNUPG:] REVKEYSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_ULTIMATE', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodButMadeByRevokedKey + })); + + it('Should parse TRUST_UNDEFINED, and apply it to a GOODSIG', testParsingGpgStatus('[GNUPG:] GOODSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_UNDEFINED', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodWithUnknownValidity + })); + + it('Should parse TRUST_NEVER, and apply it to a GOODSIG', testParsingGpgStatus('[GNUPG:] GOODSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_NEVER', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodWithUnknownValidity + })); + + it('Should parse TRUST_UNDEFINED, and NOT apply it to a BADSIG', testParsingGpgStatus('[GNUPG:] BADSIG 1234567890ABCDEF Tagger Name \n', 'TRUST_UNDEFINED', { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.Bad + })); + + it('Should return a signature with status GitSignatureStatus.CannotBeChecked when no signature can be parsed', testParsingGpgStatus('', 'TRUST_ULTIMATE', { + key: '', + signer: '', + status: GitSignatureStatus.CannotBeChecked + })); + + it('Should return a signature with status GitSignatureStatus.CannotBeChecked when multiple exclusive statuses exist', testParsingGpgStatus( + '[GNUPG:] GOODSIG 1234567890ABCDEF Tagger Name \n[GNUPG:] BADSIG 1234567890ABCDEF Tagger Name \n', + 'TRUST_ULTIMATE', + { + key: '', + signer: '', + status: GitSignatureStatus.CannotBeChecked + } + )); + + it('Should ignore records that don\'t start with "[GNUPG:]"', testParsingGpgStatus( + '[XYZ] GOODSIG 1234567890ABCDEF Tagger Name \n[GNUPG:] BADSIG 1234567890ABCDEF Tagger Name \n', + 'TRUST_ULTIMATE', + { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.Bad + } + )); + + it('Should parse signatures from stdout when there is not content on stderr (for compatibility - normally output is on stderr)', async () => { + // Setup + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\nXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbsubject1\r\nsubject2\n\nbody1\nbody2\n-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n\n'); + mockGitSuccessOnce('[GNUPG:] NEWSIG\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] SIG_ID abcdefghijklmnopqrstuvwxyza 2021-04-10 1618040201\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] GOODSIG 1234567890ABCDEF Tagger Name \r\n[GNUPG:] VALIDSIG ABCDEF1234567890ABCDEF1234567890ABCDEF12 2021-04-10 1618040201 0 4 0 1 8 00 ABCDEF1234567890ABCDEF1234567890ABCDEF12\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] TRUST_ULTIMATE 0 pgp\r\n[GNUPG:] VERIFICATION_COMPLIANCE_MODE 23\r\n'); + + // Run + const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); + + // Assert + expect(result).toStrictEqual({ + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'subject1\nsubject2\n\nbody1\nbody2', + signature: { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.GoodAndValid + } + }, + error: null + }); + expect(spyOnSpawn).toHaveBeenCalledTimes(2); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['verify-tag', '--raw', 'refs/tags/tag-name'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + it('Should parse signatures from stderr, when both stdout & stderr have content (for compatibility - normally output is on stderr)', async () => { + // Setup + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\nXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbsubject1\r\nsubject2\n\nbody1\nbody2\n-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n\n'); + mockGitSuccessOnce( + '[GNUPG:] NEWSIG\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] SIG_ID abcdefghijklmnopqrstuvwxyza 2021-04-10 1618040201\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] GOODSIG 1234567890ABCDEF Tagger Name \r\n[GNUPG:] VALIDSIG ABCDEF1234567890ABCDEF1234567890ABCDEF12 2021-04-10 1618040201 0 4 0 1 8 00 ABCDEF1234567890ABCDEF1234567890ABCDEF12\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] TRUST_ULTIMATE 0 pgp\r\n[GNUPG:] VERIFICATION_COMPLIANCE_MODE 23\r\n', + '[GNUPG:] NEWSIG\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] SIG_ID abcdefghijklmnopqrstuvwxyza 2021-04-10 1618040201\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] BADSIG 1234567890ABCDEF Tagger Name \r\n[GNUPG:] VALIDSIG ABCDEF1234567890ABCDEF1234567890ABCDEF12 2021-04-10 1618040201 0 4 0 1 8 00 ABCDEF1234567890ABCDEF1234567890ABCDEF12\r\n[GNUPG:] KEY_CONSIDERED ABCDEF1234567890ABCDEF1234567890ABCDEF12 0\r\n[GNUPG:] TRUST_ULTIMATE 0 pgp\r\n[GNUPG:] VERIFICATION_COMPLIANCE_MODE 23\r\n' + ); + + // Run + const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); + + // Assert + expect(result).toStrictEqual({ + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'subject1\nsubject2\n\nbody1\nbody2', + signature: { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.Bad + } + }, + error: null + }); + expect(spyOnSpawn).toHaveBeenCalledTimes(2); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['verify-tag', '--raw', 'refs/tags/tag-name'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); + + it('Should ignore the Git exit code when parsing signatures', async () => { + // Setup + mockGitSuccessOnce('79e88e142b378f41dfd1f82d94209a7a411384edXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbTest TaggerXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb1587559258XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\nXX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPbsubject1\r\nsubject2\n\nbody1\nbody2\n-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n\n'); + mockGitThrowingErrorOnce('[GNUPG:] BADSIG 1234567890ABCDEF Tagger Name '); + + // Run + const result = await dataSource.getTagDetails('/path/to/repo', 'tag-name'); + + // Assert + expect(result).toStrictEqual({ + details: { + hash: '79e88e142b378f41dfd1f82d94209a7a411384ed', + taggerName: 'Test Tagger', + taggerEmail: 'test@mhutchie.com', + taggerDate: 1587559258, + message: 'subject1\nsubject2\n\nbody1\nbody2', + signature: { + key: '1234567890ABCDEF', + signer: 'Tagger Name ', + status: GitSignatureStatus.Bad + } + }, + error: null + }); + expect(spyOnSpawn).toHaveBeenCalledTimes(2); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['for-each-ref', 'refs/tags/tag-name', '--format=%(objectname)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggername)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggeremail)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(taggerdate:unix)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents:signature)XX7Nal-YARtTpjCikii9nJxER19D6diSyk-AWkPb%(contents)'], expect.objectContaining({ cwd: '/path/to/repo' })); + expect(spyOnSpawn).toBeCalledWith('/path/to/git', ['verify-tag', '--raw', 'refs/tags/tag-name'], expect.objectContaining({ cwd: '/path/to/repo' })); + }); }); }); diff --git a/web/main.ts b/web/main.ts index f23b6633..645ec57a 100644 --- a/web/main.ts +++ b/web/main.ts @@ -941,7 +941,7 @@ class GitGraphView { alterClass(this.refreshBtnElem, CLASS_REFRESHING, !enabled); } - public renderTagDetails(tagName: string, tagHash: string, commitHash: string, name: string, email: string, date: number, message: string) { + public renderTagDetails(tagName: string, commitHash: string, details: GG.GitTagDetails) { const textFormatter = new TextFormatter(this.commits, this.gitRepos[this.currentRepo].issueLinkingConfig, { commits: true, emoji: true, @@ -950,13 +950,15 @@ class GitGraphView { multiline: true, urls: true }); - let html = 'Tag ' + escapeHtml(tagName) + '
'; - html += 'Object: ' + escapeHtml(tagHash) + '
'; - html += 'Commit: ' + escapeHtml(commitHash) + '
'; - html += 'Tagger: ' + escapeHtml(name) + ' <' + escapeHtml(email) + '>
'; - html += 'Date: ' + formatLongDate(date) + '

'; - html += textFormatter.format(message) + '
'; - dialog.showMessage(html); + dialog.showMessage( + 'Tag ' + escapeHtml(tagName) + '
' + + 'Object: ' + escapeHtml(details.hash) + '
' + + 'Commit: ' + escapeHtml(commitHash) + '
' + + 'Tagger: ' + escapeHtml(details.taggerName) + ' <' + escapeHtml(details.taggerEmail) + '>' + (details.signature !== null ? generateSignatureHtml(details.signature) : '') + '
' + + 'Date: ' + formatLongDate(details.taggerDate) + '

' + + textFormatter.format(details.message) + + '
' + ); } public renderRepoDropdownOptions(repo?: string) { @@ -3295,8 +3297,8 @@ window.addEventListener('load', () => { } break; case 'tagDetails': - if (msg.error === null) { - gitGraph.renderTagDetails(msg.tagName, msg.tagHash, msg.commitHash, msg.name, msg.email, msg.date, msg.message); + if (msg.details !== null) { + gitGraph.renderTagDetails(msg.tagName, msg.commitHash, msg.details); } else { dialog.showError('Unable to retrieve Tag Details', msg.error, null, null); } @@ -3808,7 +3810,7 @@ function findCommitElemWithId(elems: HTMLCollectionOf, id: number | return null; } -function generateSignatureHtml(signature: GG.GitCommitSignature) { +function generateSignatureHtml(signature: GG.GitSignature) { return '' diff --git a/web/styles/dialog.css b/web/styles/dialog.css index 88f34ed1..96906f2d 100644 --- a/web/styles/dialog.css +++ b/web/styles/dialog.css @@ -115,13 +115,15 @@ body.vscode-high-contrast .dialog{ .dialog .messageContent{ display:inline-block; margin-top:10px; + line-height:18px; text-align:left; width:100%; word-wrap:break-word; } -.dialog .errorContent{ +.dialog .messageContent.errorContent{ font-style:italic; + line-height:17px; } .dialog #dialogAction{ diff --git a/web/styles/main.css b/web/styles/main.css index 2ef10396..9e9a532f 100644 --- a/web/styles/main.css +++ b/web/styles/main.css @@ -438,6 +438,10 @@ code{ cursor:help; margin-left:4px; vertical-align:middle; + line-height:13px; +} +.signatureInfo svg{ + vertical-align:0 !important; } .signatureInfo.G svg, .signatureInfo.X svg{ fill:#009028;