From 9615e43eaf36cd5adfe8c5afc35236d0cde8af0b Mon Sep 17 00:00:00 2001 From: Kristaps Austers Date: Wed, 3 Jun 2026 11:45:03 +0300 Subject: [PATCH 1/5] Add SSH commit signing for HEAD commits --- GitUpKit/Core/GCPrivate.h | 3 + GitUpKit/Core/GCRepository+HEAD.m | 289 +++++++++++++++++++++++++++++- 2 files changed, 290 insertions(+), 2 deletions(-) diff --git a/GitUpKit/Core/GCPrivate.h b/GitUpKit/Core/GCPrivate.h index b05b63bc..513a46d1 100644 --- a/GitUpKit/Core/GCPrivate.h +++ b/GitUpKit/Core/GCPrivate.h @@ -262,6 +262,9 @@ extern int git_submodule_foreach_block(git_repository* repo, int (^block)(git_su - (void)setRemoteCallbacks:(git_remote_callbacks*)callbacks; - (NSData*)exportBlobWithOID:(const git_oid*)oid error:(NSError**)error; - (BOOL)exportBlobWithOID:(const git_oid*)oid toPath:(NSString*)path error:(NSError**)error; +#if !TARGET_OS_IPHONE +- (NSString*)getPATHUsingShell:(NSString*)shell error:(NSError**)error; +#endif @end @interface GCHistory () diff --git a/GitUpKit/Core/GCRepository+HEAD.m b/GitUpKit/Core/GCRepository+HEAD.m index 02896a46..7bf8101e 100644 --- a/GitUpKit/Core/GCRepository+HEAD.m +++ b/GitUpKit/Core/GCRepository+HEAD.m @@ -19,6 +19,268 @@ #import "GCPrivate.h" +#if !TARGET_OS_IPHONE + +static NSString* _StringFromTaskOutput(NSData* data) { + return [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +static BOOL _ReadConfigBool(GCRepository* repository, const char* variable, BOOL* value, NSError** error) { + BOOL success = NO; + git_config* config = NULL; + int boolValue = 0; + int status; + + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_config, &config, repository.private); + status = git_config_get_bool(&boolValue, config, variable); + if (status == GIT_ENOTFOUND) { + *value = NO; + success = YES; + goto cleanup; + } + CHECK_LIBGIT2_FUNCTION_CALL(goto cleanup, status, == GIT_OK); + *value = boolValue ? YES : NO; + success = YES; + +cleanup: + git_config_free(config); + return success; +} + +static BOOL _ShouldSSHSignCommit(GCRepository* repository, BOOL* shouldSign, NSError** error) { + BOOL gpgSign = NO; + if (!_ReadConfigBool(repository, "commit.gpgsign", &gpgSign, error)) { + return NO; + } + if (!gpgSign) { + *shouldSign = NO; + return YES; + } + + NSString* format = [[repository readConfigOptionForVariable:@"gpg.format" error:NULL] value]; + if (!format.length || ([format caseInsensitiveCompare:@"ssh"] != NSOrderedSame)) { + // v1 intentionally supports SSH commit signing only. Preserve existing GitUp behavior for OpenPGP/X.509 configs by creating an unsigned commit. + *shouldSign = NO; + return YES; + } + + *shouldSign = YES; + return YES; +} + +static NSString* _CommitSigningPATH(GCRepository* repository, NSError** error) { + static NSString* cachedPATH = nil; + if (cachedPATH == nil) { + cachedPATH = [repository getPATHUsingShell:NSProcessInfo.processInfo.environment[@"SHELL"] error:error] ?: [repository getPATHUsingShell:@"/bin/sh" error:error]; + XLOG_DEBUG_CHECK(cachedPATH); + } + return cachedPATH; +} + +static BOOL _LooksLikeInlineSSHKey(NSString* key) { + return [key hasPrefix:@"ssh-"] || [key hasPrefix:@"ecdsa-"] || [key hasPrefix:@"sk-"]; +} + +static NSString* _SSHKeyFromDefaultKeyCommand(GCRepository* repository, NSString* command, NSError** error) { + NSString* path = _CommitSigningPATH(repository, error); + if (!path) { + return nil; + } + + GCTask* task = [[GCTask alloc] initWithExecutablePath:@"/bin/sh"]; + task.currentDirectoryPath = repository.workingDirectoryPath ?: repository.repositoryPath; + task.additionalEnvironment = @{@"PATH" : path}; + int status; + NSData* stdoutData; + NSData* stderrData; + if (![task runWithArguments:@[ @"-c", command ] stdin:nil stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]) { + return nil; + } + if (status != 0) { + if (error) { + NSString* output = _StringFromTaskOutput(stderrData.length ? stderrData : stdoutData); + *error = GCNewError(kGCErrorCode_Generic, [NSString stringWithFormat:@"SSH signing default key command exited with non-zero status (%i)%@", status, output.length ? [NSString stringWithFormat:@": %@", output] : @""]); + } + return nil; + } + + NSString* output = [[NSString alloc] initWithData:stdoutData encoding:NSUTF8StringEncoding]; + for (NSString* line in [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) { + NSString* trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([trimmedLine hasPrefix:@"key::"]) { + NSString* key = [trimmedLine substringFromIndex:5]; + return key.length ? key : nil; + } + } + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH signing default key command did not return a key:: entry"); + } + return nil; +} + +static NSString* _TemporaryKeyFileForInlineSSHKey(NSString* key, NSError** error) { + NSString* path = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* contents = [key hasSuffix:@"\n"] ? key : [key stringByAppendingString:@"\n"]; + if (![contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:error]) { + return nil; + } + return path; +} + +static NSString* _SSHSigningKeyPath(GCRepository* repository, NSString** temporaryPath, NSError** error) { + NSString* key = [[repository readConfigOptionForVariable:@"user.signingkey" error:NULL] value]; + if (key.length) { + key = [key stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + } else { + NSString* command = [[repository readConfigOptionForVariable:@"gpg.ssh.defaultKeyCommand" error:NULL] value]; + if (!command.length) { + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signing requires user.signingkey or gpg.ssh.defaultKeyCommand"); + } + return nil; + } + key = _SSHKeyFromDefaultKeyCommand(repository, command, error); + if (!key) { + return nil; + } + } + + if ([key hasPrefix:@"key::"]) { + key = [key substringFromIndex:5]; + } + if (_LooksLikeInlineSSHKey(key)) { + NSString* path = _TemporaryKeyFileForInlineSSHKey(key, error); + if (!path) { + return nil; + } + *temporaryPath = path; + return path; + } + + NSString* path = key.stringByExpandingTildeInPath; + if (!path.absolutePath) { + NSString* workingDirectoryPath = repository.workingDirectoryPath ?: repository.repositoryPath; + NSString* relativePath = [workingDirectoryPath stringByAppendingPathComponent:path]; + if ([[NSFileManager defaultManager] fileExistsAtPath:relativePath]) { + path = relativePath; + } + } + return path; +} + +static NSString* _SSHSignatureForCommitBuffer(GCRepository* repository, NSData* commitBuffer, NSError** error) { + NSString* temporaryKeyPath = nil; + NSString* keyPath = _SSHSigningKeyPath(repository, &temporaryKeyPath, error); + if (!keyPath) { + return nil; + } + + NSString* path = _CommitSigningPATH(repository, error); + if (!path) { + if (temporaryKeyPath) { + [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; + } + return nil; + } + + NSString* program = [[repository readConfigOptionForVariable:@"gpg.ssh.program" error:NULL] value]; + program = program.length ? program.stringByExpandingTildeInPath : @"ssh-keygen"; + + GCTask* task = [[GCTask alloc] initWithExecutablePath:@"/usr/bin/env"]; + task.currentDirectoryPath = repository.workingDirectoryPath ?: repository.repositoryPath; + task.additionalEnvironment = @{@"PATH" : path}; + int status; + NSData* stdoutData; + NSData* stderrData; + NSArray* arguments = @[ program, @"-Y", @"sign", @"-n", @"git", @"-f", keyPath ]; + BOOL success = [task runWithArguments:arguments stdin:commitBuffer stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]; + if (temporaryKeyPath) { + [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; + } + if (!success) { + return nil; + } + if (status != 0) { + if (error) { + NSString* output = _StringFromTaskOutput(stderrData.length ? stderrData : stdoutData); + *error = GCNewError(kGCErrorCode_Generic, [NSString stringWithFormat:@"SSH commit signer exited with non-zero status (%i)%@", status, output.length ? [NSString stringWithFormat:@": %@", output] : @""]); + } + return nil; + } + + NSString* signature = _StringFromTaskOutput(stdoutData); + if (!signature.length) { + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signer did not return a signature"); + } + return nil; + } + return signature; +} + +#endif + +static GCCommit* _CreateCommitFromTree(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_signature* signature = NULL; + git_commit* newCommit = NULL; + NSData* cleanedMessage = nil; +#if !TARGET_OS_IPHONE + git_buf commitBuffer = {0}; + NSData* commitData = nil; + NSString* sshSignature = nil; + BOOL shouldSign = NO; +#endif + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_signature_default, &signature, repository.private); + cleanedMessage = GCCleanedUpCommitMessage(message); +#if !TARGET_OS_IPHONE + if (!_ShouldSSHSignCommit(repository, &shouldSign, error)) { + goto cleanup; + } + + if (shouldSign) { + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_buffer, &commitBuffer, repository.private, author ? author : signature, signature, NULL, cleanedMessage.bytes, tree, count, parents); + commitData = [[NSData alloc] initWithBytes:commitBuffer.ptr length:commitBuffer.size]; + sshSignature = _SSHSignatureForCommitBuffer(repository, commitData, error); + if (!sshSignature) { + goto cleanup; + } + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_with_signature, &oid, repository.private, commitBuffer.ptr, sshSignature.UTF8String, "gpgsig"); + } else { +#endif + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create, &oid, repository.private, NULL, author ? author : signature, signature, NULL, cleanedMessage.bytes, tree, count, parents); +#if !TARGET_OS_IPHONE + } +#endif + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_lookup, &newCommit, repository.private, &oid); + commit = [[GCCommit alloc] initWithRepository:repository commit:newCommit]; + newCommit = NULL; + +cleanup: + git_commit_free(newCommit); +#if !TARGET_OS_IPHONE + git_buf_free(&commitBuffer); +#endif + git_signature_free(signature); + return commit; +} + +static GCCommit* _CreateCommitFromIndex(GCRepository* repository, git_index* index, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_tree* tree = NULL; + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); + commit = _CreateCommitFromTree(repository, tree, parents, count, author, message, error); + +cleanup: + git_tree_free(tree); + return commit; +} + @implementation GCRepository (HEAD) #pragma mark - HEAD Manipulation @@ -116,7 +378,7 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N } const git_commit* parents[2] = {headCommit, parent.private}; - commit = [self createCommitFromIndex:index withParents:parents count:(headCommit ? (parent ? 2 : 1) : 0)author:NULL message:message error:error]; + commit = _CreateCommitFromIndex(self, index, parents, (headCommit ? (parent ? 2 : 1) : 0), NULL, message, error); if (commit == nil) { goto cleanup; } @@ -151,6 +413,8 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE git_reference* headReference = NULL; git_commit* headCommit = NULL; git_index* index = NULL; + git_tree* tree = NULL; + git_commit** parentCommits = NULL; NSString* reflogMessage; CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_head, &headReference, self.private); // Returns a direct reference or GIT_EUNBORNBRANCH @@ -162,7 +426,21 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE goto cleanup; } - commit = [self createCommitFromCommit:headCommit withIndex:index updatedMessage:message updatedParents:nil updateCommitter:YES error:error]; + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, self.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, self.private, &oid); + unsigned int parentCount = git_commit_parentcount(headCommit); + if (parentCount) { + parentCommits = calloc(parentCount, sizeof(git_commit*)); + if (!parentCommits) { + GC_SET_GENERIC_ERROR(@"Unable to allocate commit parent list"); + goto cleanup; + } + for (unsigned int i = 0; i < parentCount; ++i) { + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], headCommit, i); + } + } + commit = _CreateCommitFromTree(self, tree, (const git_commit**)parentCommits, parentCount, git_commit_author(headCommit), message, error); if (commit == nil) { goto cleanup; } @@ -178,6 +456,13 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE success = YES; cleanup: + if (parentCommits) { + for (unsigned int i = 0, count = headCommit ? git_commit_parentcount(headCommit) : 0; i < count; ++i) { + git_commit_free(parentCommits[i]); + } + free(parentCommits); + } + git_tree_free(tree); git_index_free(index); git_commit_free(headCommit); git_reference_free(headReference); From 82af949d7cfc95cd67138c88c5b18f9e2e32a2b0 Mon Sep 17 00:00:00 2001 From: Kristaps Austers Date: Wed, 3 Jun 2026 11:47:34 +0300 Subject: [PATCH 2/5] Test SSH commit signing for HEAD commits --- GitUpKit/Core/GCRepository+HEAD-Tests.m | 130 ++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/GitUpKit/Core/GCRepository+HEAD-Tests.m b/GitUpKit/Core/GCRepository+HEAD-Tests.m index fdfcc211..de382c2a 100644 --- a/GitUpKit/Core/GCRepository+HEAD-Tests.m +++ b/GitUpKit/Core/GCRepository+HEAD-Tests.m @@ -21,6 +21,40 @@ #import #import +static NSString* _CommitSignature(GCCommit* commit) { + git_buf buffer = {0}; + int status = git_commit_header_field(&buffer, commit.private, "gpgsig"); + if (status != GIT_OK) { + git_buf_free(&buffer); + return nil; + } + NSString* signature = [[NSString alloc] initWithBytes:buffer.ptr length:buffer.size encoding:NSUTF8StringEncoding]; + git_buf_free(&buffer); + return signature; +} + +static BOOL _ConfigureSSHSigningWithKeyPath(GCRepository* repository, NSString* keyPath) { + return [repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL] && + [repository writeConfigOptionForLevel:kGCConfigLevel_Local + variable:@"gpg.format" + withValue:@"ssh" + error:NULL] && + [repository writeConfigOptionForLevel:kGCConfigLevel_Local + variable:@"user.signingkey" + withValue:keyPath + error:NULL]; +} + +static NSString* _CreateFakeSSHSigner(NSString* directory, int exitStatus) { + NSString* path = [directory stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* contents = exitStatus == 0 ? @"#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '-----BEGIN SSH SIGNATURE-----' 'fake-signature' '-----END SSH SIGNATURE-----'\n" + : [NSString stringWithFormat:@"#!/bin/sh\ncat >/dev/null\necho signer failed >&2\nexit %i\n", exitStatus]; + return ([contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:NULL] && + [[NSFileManager defaultManager] setAttributes:@{NSFilePosixPermissions : @(0755)} ofItemAtPath:path error:NULL]) + ? path + : nil; +} + @implementation GCEmptyRepositoryTests (GCRepository_HEAD) - (void)testUnbornHEAD { @@ -34,6 +68,69 @@ - (void)testUnbornHEAD { XCTAssertFalse(self.repository.HEADUnborn); } +- (void)testUnsignedCommitWhenSigningDisabledOrUnsupported { + [self updateFileAtPath:@"unsigned.txt" withString:@"unsigned\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"unsigned.txt" error:NULL]); + GCCommit* unsignedCommit = [self.repository createCommitFromHEADWithMessage:@"Unsigned" error:NULL]; + XCTAssertNotNil(unsignedCommit); + XCTAssertNil(_CommitSignature(unsignedCommit)); + + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"openpgp" error:NULL]); + [self updateFileAtPath:@"openpgp.txt" withString:@"openpgp\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"openpgp.txt" error:NULL]); + GCCommit* openPGPCommit = [self.repository createCommitFromHEADWithMessage:@"OpenPGP config remains unsigned" error:NULL]; + XCTAssertNotNil(openPGPCommit); + XCTAssertNil(_CommitSignature(openPGPCommit)); +} + +- (void)testSSHSigningRequiresKey { + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"ssh" error:NULL]); + [self updateFileAtPath:@"missing-key.txt" withString:@"missing key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"missing-key.txt" error:NULL]); + NSError* error; + XCTAssertNil([self.repository createCommitFromHEADWithMessage:@"Missing key" error:&error]); + XCTAssertTrue([error.localizedDescription containsString:@"user.signingkey"]); +} + +- (void)testSSHSigningSupportsInlineKeyAndDefaultKeyCommand { + NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 0); + XCTAssertNotNil(signer); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"ssh" error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.ssh.program" withValue:signer error:NULL]); + + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"user.signingkey" withValue:@"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeInlineKey test@example.com" error:NULL]); + [self updateFileAtPath:@"inline-key.txt" withString:@"inline key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"inline-key.txt" error:NULL]); + GCCommit* inlineCommit = [self.repository createCommitFromHEADWithMessage:@"Inline key" error:NULL]; + XCTAssertNotNil(inlineCommit); + XCTAssertTrue([_CommitSignature(inlineCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"user.signingkey" withValue:nil error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.ssh.defaultKeyCommand" withValue:@"printf 'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDefaultCommandKey test@example.com\\n'" error:NULL]); + [self updateFileAtPath:@"default-key-command.txt" withString:@"default key command\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"default-key-command.txt" error:NULL]); + GCCommit* defaultCommandCommit = [self.repository createCommitFromHEADWithMessage:@"Default key command" error:NULL]; + XCTAssertNotNil(defaultCommandCommit); + XCTAssertTrue([_CommitSignature(defaultCommandCommit) containsString:@"BEGIN SSH SIGNATURE"]); +} + +- (void)testSSHSignerFailureFailsCommit { + NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 7); + XCTAssertNotNil(signer); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"ssh" error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.ssh.program" withValue:signer error:NULL]); + XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"user.signingkey" withValue:@"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFailingSignerKey test@example.com" error:NULL]); + [self updateFileAtPath:@"failing-signer.txt" withString:@"failing signer\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"failing-signer.txt" error:NULL]); + NSError* error; + XCTAssertNil([self.repository createCommitFromHEADWithMessage:@"Failing signer" error:&error]); + XCTAssertTrue([error.localizedDescription containsString:@"non-zero status"]); +} + @end @implementation GCMultipleCommitsRepositoryTests (GCRepository_HEAD) @@ -111,6 +208,39 @@ - (void)testHEAD { XCTAssertFalse([self.repository checkClean:0 error:NULL]); } +- (void)testSSHSignsUserFacingCommits { + NSString* keyPath = [self.temporaryPath stringByAppendingPathComponent:@"signing_key"]; + GCTask* keygen = [[GCTask alloc] initWithExecutablePath:@"/usr/bin/ssh-keygen"]; + int status; + NSArray* keygenArguments = @[ @"-t", @"ed25519", @"-f", keyPath, @"-N", @"", @"-q" ]; + BOOL keygenSuccess = [keygen runWithArguments:keygenArguments stdin:nil stdout:NULL stderr:NULL exitStatus:&status error:NULL]; + XCTAssertTrue(keygenSuccess); + XCTAssertEqual(status, 0); + XCTAssertTrue(_ConfigureSSHSigningWithKeyPath(self.repository, keyPath)); + + GCCommit* emptyCommit = [self.repository createCommitFromHEADWithMessage:@"Signed empty" error:NULL]; + XCTAssertNotNil(emptyCommit); + XCTAssertTrue([_CommitSignature(emptyCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + GCCommit* mergeCommit = [self.repository createCommitFromHEADAndOtherParent:self.commitA withMessage:@"Signed merge" error:NULL]; + XCTAssertNotNil(mergeCommit); + XCTAssertTrue([_CommitSignature(mergeCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + [self updateFileAtPath:@"hello_world.txt" withString:@"SIGNED AMEND\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"hello_world.txt" error:NULL]); + GCCommit* amendCommit = [self.repository createCommitByAmendingHEADWithMessage:@"Signed amend" error:NULL]; + XCTAssertNotNil(amendCommit); + XCTAssertTrue([_CommitSignature(amendCommit) containsString:@"BEGIN SSH SIGNATURE"]); + + NSString* publicKey = [NSString stringWithContentsOfFile:[keyPath stringByAppendingString:@".pub"] encoding:NSUTF8StringEncoding error:NULL]; + NSString* allowedSignersPath = [self.temporaryPath stringByAppendingPathComponent:@"allowed_signers"]; + NSString* allowedSigners = [NSString stringWithFormat:@"bot@example.com %@", publicKey]; + XCTAssertTrue([allowedSigners writeToFile:allowedSignersPath atomically:YES encoding:NSUTF8StringEncoding error:NULL]); + NSString* allowedSignersConfig = [NSString stringWithFormat:@"gpg.ssh.allowedSignersFile=%@", allowedSignersPath]; + NSString* verifyOutput = [self runGitCLTWithRepository:self.repository command:@"-c", allowedSignersConfig, @"verify-commit", amendCommit.SHA1, nil]; + XCTAssertNotNil(verifyOutput); +} + - (void)testCheckoutFileToWorkingDirectory { // Working directory should have content from commit3 (master) [self assertContentsOfFileAtPath:@"hello_world.txt" equalsString:@"Hola Mundo!\n"]; From 98851ac9919928392e8786314735c32ab7dbbe3f Mon Sep 17 00:00:00 2001 From: Kristaps Austers Date: Wed, 3 Jun 2026 11:54:54 +0300 Subject: [PATCH 3/5] Extract SSH commit signing helper --- GitUpKit/Core/GCCommitSigning.m | 282 ++++++++++++++++++++ GitUpKit/Core/GCPrivate.h | 1 + GitUpKit/Core/GCRepository+HEAD.m | 261 +----------------- GitUpKit/GitUpKit.xcodeproj/project.pbxproj | 8 + 4 files changed, 299 insertions(+), 253 deletions(-) create mode 100644 GitUpKit/Core/GCCommitSigning.m diff --git a/GitUpKit/Core/GCCommitSigning.m b/GitUpKit/Core/GCCommitSigning.m new file mode 100644 index 00000000..e68b22a3 --- /dev/null +++ b/GitUpKit/Core/GCCommitSigning.m @@ -0,0 +1,282 @@ +// Copyright (C) 2015-2019 Pierre-Olivier Latour +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#if !__has_feature(objc_arc) +#error This file requires ARC +#endif + +#import "GCPrivate.h" + +#if !TARGET_OS_IPHONE + +static NSString* _StringFromTaskOutput(NSData* data) { + return [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; +} + +static NSString* _RepositoryTaskDirectoryPath(GCRepository* repository) { + return repository.workingDirectoryPath ?: repository.repositoryPath; +} + +static NSError* _TaskFailureError(NSString* message, int status, NSData* stdoutData, NSData* stderrData) { + NSString* output = _StringFromTaskOutput(stderrData.length ? stderrData : stdoutData); + NSString* reason = output.length ? [NSString stringWithFormat:@": %@", output] : @""; + return GCNewError(kGCErrorCode_Generic, [NSString stringWithFormat:@"%@ exited with non-zero status (%i)%@", message, status, reason]); +} + +static GCTask* _TaskWithPATH(GCRepository* repository, NSString* executablePath, NSString* path) { + GCTask* task = [[GCTask alloc] initWithExecutablePath:executablePath]; + task.currentDirectoryPath = _RepositoryTaskDirectoryPath(repository); + task.additionalEnvironment = @{@"PATH" : path}; + return task; +} + +static BOOL _ReadConfigBool(GCRepository* repository, const char* variable, BOOL* value, NSError** error) { + BOOL success = NO; + git_config* config = NULL; + int configValue = 0; + int status; + + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_config, &config, repository.private); + status = git_config_get_bool(&configValue, config, variable); + if (status == GIT_ENOTFOUND) { + *value = NO; + success = YES; + goto cleanup; + } + CHECK_LIBGIT2_FUNCTION_CALL(goto cleanup, status, == GIT_OK); + *value = configValue ? YES : NO; + success = YES; + +cleanup: + git_config_free(config); + return success; +} + +static BOOL _ShouldSSHSignCommit(GCRepository* repository, BOOL* shouldSign, NSError** error) { + BOOL gpgSign = NO; + if (!_ReadConfigBool(repository, "commit.gpgsign", &gpgSign, error)) { + return NO; + } + if (!gpgSign) { + *shouldSign = NO; + return YES; + } + + NSString* format = [[repository readConfigOptionForVariable:@"gpg.format" error:NULL] value]; + if (!format.length || ([format caseInsensitiveCompare:@"ssh"] != NSOrderedSame)) { + // Only SSH commit signing is currently supported. + // Preserve existing GitUp behavior for OpenPGP/X.509 configs by creating an unsigned commit. + *shouldSign = NO; + return YES; + } + + *shouldSign = YES; + return YES; +} + +static NSString* _CommitSigningPATH(GCRepository* repository, NSError** error) { + static NSString* cachedPATH = nil; + if (cachedPATH == nil) { + NSString* shell = NSProcessInfo.processInfo.environment[@"SHELL"]; + cachedPATH = [repository getPATHUsingShell:shell error:error] ?: [repository getPATHUsingShell:@"/bin/sh" error:error]; + XLOG_DEBUG_CHECK(cachedPATH); + } + return cachedPATH; +} + +static BOOL _LooksLikeInlineSSHKey(NSString* key) { + return [key hasPrefix:@"ssh-"] || [key hasPrefix:@"ecdsa-"] || [key hasPrefix:@"sk-"]; +} + +static NSString* _SSHKeyFromDefaultKeyCommand(GCRepository* repository, NSString* command, NSError** error) { + NSString* path = _CommitSigningPATH(repository, error); + if (!path) { + return nil; + } + + GCTask* task = _TaskWithPATH(repository, @"/bin/sh", path); + int status; + NSData* stdoutData; + NSData* stderrData; + if (![task runWithArguments:@[ @"-c", command ] stdin:nil stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]) { + return nil; + } + if (status != 0) { + if (error) { + *error = _TaskFailureError(@"SSH signing default key command", status, stdoutData, stderrData); + } + return nil; + } + + NSString* output = [[NSString alloc] initWithData:stdoutData encoding:NSUTF8StringEncoding]; + for (NSString* line in [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) { + NSString* trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if ([trimmedLine hasPrefix:@"key::"]) { + NSString* key = [trimmedLine substringFromIndex:5]; + return key.length ? key : nil; + } + } + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH signing default key command did not return a key:: entry"); + } + return nil; +} + +static NSString* _TemporaryKeyFileForInlineSSHKey(NSString* key, NSError** error) { + NSString* path = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* contents = [key hasSuffix:@"\n"] ? key : [key stringByAppendingString:@"\n"]; + if (![contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:error]) { + return nil; + } + return path; +} + +static NSString* _SSHSigningKeyPath(GCRepository* repository, NSString** temporaryPath, NSError** error) { + NSString* key = [[repository readConfigOptionForVariable:@"user.signingkey" error:NULL] value]; + if (key.length) { + key = [key stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + } else { + NSString* command = [[repository readConfigOptionForVariable:@"gpg.ssh.defaultKeyCommand" error:NULL] value]; + if (!command.length) { + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signing requires user.signingkey or gpg.ssh.defaultKeyCommand"); + } + return nil; + } + key = _SSHKeyFromDefaultKeyCommand(repository, command, error); + if (!key) { + return nil; + } + } + + if ([key hasPrefix:@"key::"]) { + key = [key substringFromIndex:5]; + } + if (_LooksLikeInlineSSHKey(key)) { + NSString* path = _TemporaryKeyFileForInlineSSHKey(key, error); + if (!path) { + return nil; + } + *temporaryPath = path; + return path; + } + + NSString* path = key.stringByExpandingTildeInPath; + if (!path.absolutePath) { + NSString* relativePath = [_RepositoryTaskDirectoryPath(repository) stringByAppendingPathComponent:path]; + if ([[NSFileManager defaultManager] fileExistsAtPath:relativePath]) { + path = relativePath; + } + } + return path; +} + +static NSString* _SSHSignatureForCommitBuffer(GCRepository* repository, NSData* commitBuffer, NSError** error) { + NSString* temporaryKeyPath = nil; + NSString* keyPath = _SSHSigningKeyPath(repository, &temporaryKeyPath, error); + if (!keyPath) { + return nil; + } + + NSString* path = _CommitSigningPATH(repository, error); + if (!path) { + if (temporaryKeyPath) { + [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; + } + return nil; + } + + NSString* program = [[repository readConfigOptionForVariable:@"gpg.ssh.program" error:NULL] value]; + program = program.length ? program.stringByExpandingTildeInPath : @"ssh-keygen"; + + GCTask* task = _TaskWithPATH(repository, @"/usr/bin/env", path); + int status; + NSData* stdoutData; + NSData* stderrData; + NSArray* arguments = @[ program, @"-Y", @"sign", @"-n", @"git", @"-f", keyPath ]; + BOOL success = [task runWithArguments:arguments stdin:commitBuffer stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]; + if (temporaryKeyPath) { + [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; + } + if (!success) { + return nil; + } + if (status != 0) { + if (error) { + *error = _TaskFailureError(@"SSH commit signer", status, stdoutData, stderrData); + } + return nil; + } + + NSString* signature = _StringFromTaskOutput(stdoutData); + if (!signature.length) { + if (error) { + *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signer did not return a signature"); + } + return nil; + } + return signature; +} + +#endif + +GCCommit* GCCreateCommitFromTreeWithOptionalSignature(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { + GCCommit* commit = nil; + const git_signature* authorSignature = NULL; + git_signature* signature = NULL; + git_commit* newCommit = NULL; + NSData* cleanedMessage = nil; +#if !TARGET_OS_IPHONE + git_buf commitBuffer = {0}; + NSData* commitData = nil; + NSString* sshSignature = nil; + BOOL shouldSign = NO; +#endif + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_signature_default, &signature, repository.private); + authorSignature = author ?: signature; + cleanedMessage = GCCleanedUpCommitMessage(message); +#if !TARGET_OS_IPHONE + if (!_ShouldSSHSignCommit(repository, &shouldSign, error)) { + goto cleanup; + } + + if (shouldSign) { + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_buffer, &commitBuffer, repository.private, authorSignature, signature, NULL, cleanedMessage.bytes, tree, count, parents); + commitData = [[NSData alloc] initWithBytes:commitBuffer.ptr length:commitBuffer.size]; + sshSignature = _SSHSignatureForCommitBuffer(repository, commitData, error); + if (!sshSignature) { + goto cleanup; + } + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_with_signature, &oid, repository.private, commitBuffer.ptr, sshSignature.UTF8String, "gpgsig"); + } else { +#endif + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create, &oid, repository.private, NULL, authorSignature, signature, NULL, cleanedMessage.bytes, tree, count, parents); +#if !TARGET_OS_IPHONE + } +#endif + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_lookup, &newCommit, repository.private, &oid); + commit = [[GCCommit alloc] initWithRepository:repository commit:newCommit]; + newCommit = NULL; + +cleanup: + git_commit_free(newCommit); +#if !TARGET_OS_IPHONE + git_buf_free(&commitBuffer); +#endif + git_signature_free(signature); + return commit; +} diff --git a/GitUpKit/Core/GCPrivate.h b/GitUpKit/Core/GCPrivate.h index 513a46d1..0b5db3bb 100644 --- a/GitUpKit/Core/GCPrivate.h +++ b/GitUpKit/Core/GCPrivate.h @@ -118,6 +118,7 @@ extern NSString* GCGitOIDToSHA1(const git_oid* oid); extern BOOL GCGitOIDFromSHA1(NSString* sha1, git_oid* oid, NSError** error); extern BOOL GCGitOIDFromSHA1Prefix(NSString* prefix, git_oid* oid, NSError** error); extern NSData* GCCleanedUpCommitMessage(NSString* message); +extern GCCommit* GCCreateCommitFromTreeWithOptionalSignature(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error); extern NSString* GCUserFromSignature(const git_signature* signature); extern const void* GCOIDCopyCallBack(CFAllocatorRef allocator, const void* value); extern Boolean GCOIDEqualCallBack(const void* value1, const void* value2); diff --git a/GitUpKit/Core/GCRepository+HEAD.m b/GitUpKit/Core/GCRepository+HEAD.m index 7bf8101e..3b27839e 100644 --- a/GitUpKit/Core/GCRepository+HEAD.m +++ b/GitUpKit/Core/GCRepository+HEAD.m @@ -19,254 +19,6 @@ #import "GCPrivate.h" -#if !TARGET_OS_IPHONE - -static NSString* _StringFromTaskOutput(NSData* data) { - return [[[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; -} - -static BOOL _ReadConfigBool(GCRepository* repository, const char* variable, BOOL* value, NSError** error) { - BOOL success = NO; - git_config* config = NULL; - int boolValue = 0; - int status; - - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_config, &config, repository.private); - status = git_config_get_bool(&boolValue, config, variable); - if (status == GIT_ENOTFOUND) { - *value = NO; - success = YES; - goto cleanup; - } - CHECK_LIBGIT2_FUNCTION_CALL(goto cleanup, status, == GIT_OK); - *value = boolValue ? YES : NO; - success = YES; - -cleanup: - git_config_free(config); - return success; -} - -static BOOL _ShouldSSHSignCommit(GCRepository* repository, BOOL* shouldSign, NSError** error) { - BOOL gpgSign = NO; - if (!_ReadConfigBool(repository, "commit.gpgsign", &gpgSign, error)) { - return NO; - } - if (!gpgSign) { - *shouldSign = NO; - return YES; - } - - NSString* format = [[repository readConfigOptionForVariable:@"gpg.format" error:NULL] value]; - if (!format.length || ([format caseInsensitiveCompare:@"ssh"] != NSOrderedSame)) { - // v1 intentionally supports SSH commit signing only. Preserve existing GitUp behavior for OpenPGP/X.509 configs by creating an unsigned commit. - *shouldSign = NO; - return YES; - } - - *shouldSign = YES; - return YES; -} - -static NSString* _CommitSigningPATH(GCRepository* repository, NSError** error) { - static NSString* cachedPATH = nil; - if (cachedPATH == nil) { - cachedPATH = [repository getPATHUsingShell:NSProcessInfo.processInfo.environment[@"SHELL"] error:error] ?: [repository getPATHUsingShell:@"/bin/sh" error:error]; - XLOG_DEBUG_CHECK(cachedPATH); - } - return cachedPATH; -} - -static BOOL _LooksLikeInlineSSHKey(NSString* key) { - return [key hasPrefix:@"ssh-"] || [key hasPrefix:@"ecdsa-"] || [key hasPrefix:@"sk-"]; -} - -static NSString* _SSHKeyFromDefaultKeyCommand(GCRepository* repository, NSString* command, NSError** error) { - NSString* path = _CommitSigningPATH(repository, error); - if (!path) { - return nil; - } - - GCTask* task = [[GCTask alloc] initWithExecutablePath:@"/bin/sh"]; - task.currentDirectoryPath = repository.workingDirectoryPath ?: repository.repositoryPath; - task.additionalEnvironment = @{@"PATH" : path}; - int status; - NSData* stdoutData; - NSData* stderrData; - if (![task runWithArguments:@[ @"-c", command ] stdin:nil stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]) { - return nil; - } - if (status != 0) { - if (error) { - NSString* output = _StringFromTaskOutput(stderrData.length ? stderrData : stdoutData); - *error = GCNewError(kGCErrorCode_Generic, [NSString stringWithFormat:@"SSH signing default key command exited with non-zero status (%i)%@", status, output.length ? [NSString stringWithFormat:@": %@", output] : @""]); - } - return nil; - } - - NSString* output = [[NSString alloc] initWithData:stdoutData encoding:NSUTF8StringEncoding]; - for (NSString* line in [output componentsSeparatedByCharactersInSet:[NSCharacterSet newlineCharacterSet]]) { - NSString* trimmedLine = [line stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - if ([trimmedLine hasPrefix:@"key::"]) { - NSString* key = [trimmedLine substringFromIndex:5]; - return key.length ? key : nil; - } - } - if (error) { - *error = GCNewError(kGCErrorCode_Generic, @"SSH signing default key command did not return a key:: entry"); - } - return nil; -} - -static NSString* _TemporaryKeyFileForInlineSSHKey(NSString* key, NSError** error) { - NSString* path = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; - NSString* contents = [key hasSuffix:@"\n"] ? key : [key stringByAppendingString:@"\n"]; - if (![contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:error]) { - return nil; - } - return path; -} - -static NSString* _SSHSigningKeyPath(GCRepository* repository, NSString** temporaryPath, NSError** error) { - NSString* key = [[repository readConfigOptionForVariable:@"user.signingkey" error:NULL] value]; - if (key.length) { - key = [key stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - } else { - NSString* command = [[repository readConfigOptionForVariable:@"gpg.ssh.defaultKeyCommand" error:NULL] value]; - if (!command.length) { - if (error) { - *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signing requires user.signingkey or gpg.ssh.defaultKeyCommand"); - } - return nil; - } - key = _SSHKeyFromDefaultKeyCommand(repository, command, error); - if (!key) { - return nil; - } - } - - if ([key hasPrefix:@"key::"]) { - key = [key substringFromIndex:5]; - } - if (_LooksLikeInlineSSHKey(key)) { - NSString* path = _TemporaryKeyFileForInlineSSHKey(key, error); - if (!path) { - return nil; - } - *temporaryPath = path; - return path; - } - - NSString* path = key.stringByExpandingTildeInPath; - if (!path.absolutePath) { - NSString* workingDirectoryPath = repository.workingDirectoryPath ?: repository.repositoryPath; - NSString* relativePath = [workingDirectoryPath stringByAppendingPathComponent:path]; - if ([[NSFileManager defaultManager] fileExistsAtPath:relativePath]) { - path = relativePath; - } - } - return path; -} - -static NSString* _SSHSignatureForCommitBuffer(GCRepository* repository, NSData* commitBuffer, NSError** error) { - NSString* temporaryKeyPath = nil; - NSString* keyPath = _SSHSigningKeyPath(repository, &temporaryKeyPath, error); - if (!keyPath) { - return nil; - } - - NSString* path = _CommitSigningPATH(repository, error); - if (!path) { - if (temporaryKeyPath) { - [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; - } - return nil; - } - - NSString* program = [[repository readConfigOptionForVariable:@"gpg.ssh.program" error:NULL] value]; - program = program.length ? program.stringByExpandingTildeInPath : @"ssh-keygen"; - - GCTask* task = [[GCTask alloc] initWithExecutablePath:@"/usr/bin/env"]; - task.currentDirectoryPath = repository.workingDirectoryPath ?: repository.repositoryPath; - task.additionalEnvironment = @{@"PATH" : path}; - int status; - NSData* stdoutData; - NSData* stderrData; - NSArray* arguments = @[ program, @"-Y", @"sign", @"-n", @"git", @"-f", keyPath ]; - BOOL success = [task runWithArguments:arguments stdin:commitBuffer stdout:&stdoutData stderr:&stderrData exitStatus:&status error:error]; - if (temporaryKeyPath) { - [[NSFileManager defaultManager] removeItemAtPath:temporaryKeyPath error:NULL]; - } - if (!success) { - return nil; - } - if (status != 0) { - if (error) { - NSString* output = _StringFromTaskOutput(stderrData.length ? stderrData : stdoutData); - *error = GCNewError(kGCErrorCode_Generic, [NSString stringWithFormat:@"SSH commit signer exited with non-zero status (%i)%@", status, output.length ? [NSString stringWithFormat:@": %@", output] : @""]); - } - return nil; - } - - NSString* signature = _StringFromTaskOutput(stdoutData); - if (!signature.length) { - if (error) { - *error = GCNewError(kGCErrorCode_Generic, @"SSH commit signer did not return a signature"); - } - return nil; - } - return signature; -} - -#endif - -static GCCommit* _CreateCommitFromTree(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { - GCCommit* commit = nil; - git_signature* signature = NULL; - git_commit* newCommit = NULL; - NSData* cleanedMessage = nil; -#if !TARGET_OS_IPHONE - git_buf commitBuffer = {0}; - NSData* commitData = nil; - NSString* sshSignature = nil; - BOOL shouldSign = NO; -#endif - - git_oid oid; - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_signature_default, &signature, repository.private); - cleanedMessage = GCCleanedUpCommitMessage(message); -#if !TARGET_OS_IPHONE - if (!_ShouldSSHSignCommit(repository, &shouldSign, error)) { - goto cleanup; - } - - if (shouldSign) { - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_buffer, &commitBuffer, repository.private, author ? author : signature, signature, NULL, cleanedMessage.bytes, tree, count, parents); - commitData = [[NSData alloc] initWithBytes:commitBuffer.ptr length:commitBuffer.size]; - sshSignature = _SSHSignatureForCommitBuffer(repository, commitData, error); - if (!sshSignature) { - goto cleanup; - } - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_with_signature, &oid, repository.private, commitBuffer.ptr, sshSignature.UTF8String, "gpgsig"); - } else { -#endif - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create, &oid, repository.private, NULL, author ? author : signature, signature, NULL, cleanedMessage.bytes, tree, count, parents); -#if !TARGET_OS_IPHONE - } -#endif - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_lookup, &newCommit, repository.private, &oid); - commit = [[GCCommit alloc] initWithRepository:repository commit:newCommit]; - newCommit = NULL; - -cleanup: - git_commit_free(newCommit); -#if !TARGET_OS_IPHONE - git_buf_free(&commitBuffer); -#endif - git_signature_free(signature); - return commit; -} - static GCCommit* _CreateCommitFromIndex(GCRepository* repository, git_index* index, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error) { GCCommit* commit = nil; git_tree* tree = NULL; @@ -274,7 +26,7 @@ static BOOL _LooksLikeInlineSSHKey(NSString* key) { git_oid oid; CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); - commit = _CreateCommitFromTree(repository, tree, parents, count, author, message, error); + commit = GCCreateCommitFromTreeWithOptionalSignature(repository, tree, parents, count, author, message, error); cleanup: git_tree_free(tree); @@ -360,6 +112,7 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N git_reference* headReference = NULL; git_commit* headCommit = NULL; git_index* index = NULL; + const git_commit* parents[2] = {NULL, NULL}; NSString* reflogMessage; int status = git_repository_head(&headReference, self.private); // Returns a direct reference or GIT_EUNBORNBRANCH @@ -377,7 +130,8 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N goto cleanup; } - const git_commit* parents[2] = {headCommit, parent.private}; + parents[0] = headCommit; + parents[1] = parent.private; commit = _CreateCommitFromIndex(self, index, parents, (headCommit ? (parent ? 2 : 1) : 0), NULL, message, error); if (commit == nil) { goto cleanup; @@ -415,6 +169,7 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE git_index* index = NULL; git_tree* tree = NULL; git_commit** parentCommits = NULL; + unsigned int parentCount = 0; NSString* reflogMessage; CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_head, &headReference, self.private); // Returns a direct reference or GIT_EUNBORNBRANCH @@ -429,9 +184,9 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE git_oid oid; CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, self.private); CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, self.private, &oid); - unsigned int parentCount = git_commit_parentcount(headCommit); + parentCount = git_commit_parentcount(headCommit); if (parentCount) { - parentCommits = calloc(parentCount, sizeof(git_commit*)); + parentCommits = (git_commit**)calloc(parentCount, sizeof(git_commit*)); if (!parentCommits) { GC_SET_GENERIC_ERROR(@"Unable to allocate commit parent list"); goto cleanup; @@ -440,7 +195,7 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], headCommit, i); } } - commit = _CreateCommitFromTree(self, tree, (const git_commit**)parentCommits, parentCount, git_commit_author(headCommit), message, error); + commit = GCCreateCommitFromTreeWithOptionalSignature(self, tree, (const git_commit**)parentCommits, parentCount, git_commit_author(headCommit), message, error); if (commit == nil) { goto cleanup; } diff --git a/GitUpKit/GitUpKit.xcodeproj/project.pbxproj b/GitUpKit/GitUpKit.xcodeproj/project.pbxproj index 36820fd7..eedbbd8d 100644 --- a/GitUpKit/GitUpKit.xcodeproj/project.pbxproj +++ b/GitUpKit/GitUpKit.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ E21753671B91634C00BE234A /* GCCommit.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338DE19F85C8600063D95 /* GCCommit.m */; }; E21753681B91634C00BE234A /* GCCommitDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; E21753691B91634C00BE234A /* GCDiff.m in Sources */ = {isa = PBXBuildFile; fileRef = E2B14B5D1A8A764400003E64 /* GCDiff.m */; }; + E217536E1B91634C00BE234A /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; E217536A1B91634C00BE234A /* GCFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = E2790D411ACB1B1100965A98 /* GCFoundation.m */; }; E217536B1B91634C00BE234A /* GCFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = E21739F21A4FE39E00EC6777 /* GCFunctions.m */; }; E217536C1B91634C00BE234A /* GCHistory.m in Sources */ = {isa = PBXBuildFile; fileRef = E20F10F21A043E2100076AAC /* GCHistory.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; @@ -185,6 +186,7 @@ E267E1D21B84D83100BAB377 /* GCCommit.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338DE19F85C8600063D95 /* GCCommit.m */; }; E267E1D31B84D83100BAB377 /* GCCommitDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; E267E1D41B84D83100BAB377 /* GCDiff.m in Sources */ = {isa = PBXBuildFile; fileRef = E2B14B5D1A8A764400003E64 /* GCDiff.m */; }; + E267E1DE1B84D83100BAB377 /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; E267E1D51B84D83100BAB377 /* GCFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = E2790D411ACB1B1100965A98 /* GCFoundation.m */; }; E267E1D61B84D83100BAB377 /* GCFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = E21739F21A4FE39E00EC6777 /* GCFunctions.m */; }; E267E1D71B84D83100BAB377 /* GCHistory.m in Sources */ = {isa = PBXBuildFile; fileRef = E20F10F21A043E2100076AAC /* GCHistory.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; @@ -348,6 +350,7 @@ E2C338F019F85C8600063D95 /* GCReference.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E119F85C8600063D95 /* GCReference.m */; }; E2C338F219F85C8600063D95 /* GCRemote.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E319F85C8600063D95 /* GCRemote.m */; }; E2C338F419F85C8600063D95 /* GCRepository.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E519F85C8600063D95 /* GCRepository.m */; }; + E2C338F519F85C8600063D95 /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; E2C338F619F85C8600063D95 /* GCStash.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E719F85C8600063D95 /* GCStash.m */; }; E2C338F819F85C8600063D95 /* GCTag.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C338E919F85C8600063D95 /* GCTag.m */; }; E2C3AA5C19FF0B0600BA89F3 /* GCRepository+Bare.m in Sources */ = {isa = PBXBuildFile; fileRef = E2C3AA5A19FF0B0600BA89F3 /* GCRepository+Bare.m */; }; @@ -408,6 +411,7 @@ E2146C911A58D83100F4550B /* GCReflogMessages.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GCReflogMessages.h; sourceTree = ""; }; E21739F11A4FE39E00EC6777 /* GCFunctions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCFunctions.h; sourceTree = ""; }; E21739F21A4FE39E00EC6777 /* GCFunctions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCFunctions.m; sourceTree = ""; }; + E21753651B91634C00BE234A /* GCCommitSigning.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCCommitSigning.m; sourceTree = ""; }; E21739FC1A51FA6200EC6777 /* GCSubmodule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCSubmodule.h; sourceTree = ""; }; E21739FD1A51FA6200EC6777 /* GCSubmodule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCSubmodule.m; sourceTree = ""; }; E217531B1B91613300BE234A /* GitUpKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GitUpKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -831,6 +835,7 @@ E259C2E41A6624DC0079616B /* GCCommit-Tests.m */, E2C338DD19F85C8600063D95 /* GCCommit.h */, E2C338DE19F85C8600063D95 /* GCCommit.m */, + E21753651B91634C00BE234A /* GCCommitSigning.m */, E2FEED441AEAA6AD00CBED80 /* GCCommitDatabase.h */, E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */, E2FEED481AEAA6B500CBED80 /* GCCommitDatabase-Tests.m */, @@ -1266,6 +1271,7 @@ E21753671B91634C00BE234A /* GCCommit.m in Sources */, E21753681B91634C00BE234A /* GCCommitDatabase.m in Sources */, E21753691B91634C00BE234A /* GCDiff.m in Sources */, + E217536E1B91634C00BE234A /* GCCommitSigning.m in Sources */, E217536A1B91634C00BE234A /* GCFoundation.m in Sources */, E217536B1B91634C00BE234A /* GCFunctions.m in Sources */, E217536C1B91634C00BE234A /* GCHistory.m in Sources */, @@ -1312,6 +1318,7 @@ E267E1D21B84D83100BAB377 /* GCCommit.m in Sources */, E267E1D31B84D83100BAB377 /* GCCommitDatabase.m in Sources */, E267E1D41B84D83100BAB377 /* GCDiff.m in Sources */, + E267E1DE1B84D83100BAB377 /* GCCommitSigning.m in Sources */, E267E1D51B84D83100BAB377 /* GCFoundation.m in Sources */, E267E1D61B84D83100BAB377 /* GCFunctions.m in Sources */, E267E1D71B84D83100BAB377 /* GCHistory.m in Sources */, @@ -1406,6 +1413,7 @@ E2790D4A1ACF12E200965A98 /* GCRepository+Index.m in Sources */, DC040FC52BC9FECC00DF54D5 /* GCLiveRepository-Tests.m in Sources */, E27E43021A74A94700D04ED1 /* GIGraph-Tests.m in Sources */, + E2C338F519F85C8600063D95 /* GCCommitSigning.m in Sources */, E259C2C71A64C9980079616B /* GCRepository-Tests.m in Sources */, E24509031A9A50F3003E602D /* GCRepository+Config-Tests.m in Sources */, E2B1BF361A85C5ED00A999DF /* GIFunctions-Tests.m in Sources */, From b5d973fe7b06a99565c513b076744eebea2107ac Mon Sep 17 00:00:00 2001 From: Kristaps Austers Date: Wed, 3 Jun 2026 12:05:16 +0300 Subject: [PATCH 4/5] Split commit signing tests --- GitUpKit/Core/GCCommitSigning-Tests.m | 216 ++++++++++++++++++++ GitUpKit/Core/GCRepository+HEAD-Tests.m | 73 ------- GitUpKit/Core/GCRepository+HEAD.m | 2 + GitUpKit/GitUpKit.xcodeproj/project.pbxproj | 4 + 4 files changed, 222 insertions(+), 73 deletions(-) create mode 100644 GitUpKit/Core/GCCommitSigning-Tests.m diff --git a/GitUpKit/Core/GCCommitSigning-Tests.m b/GitUpKit/Core/GCCommitSigning-Tests.m new file mode 100644 index 00000000..09506fcf --- /dev/null +++ b/GitUpKit/Core/GCCommitSigning-Tests.m @@ -0,0 +1,216 @@ +// Copyright (C) 2015-2019 Pierre-Olivier Latour +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#if !__has_feature(objc_arc) +#error This file requires ARC +#endif + +#import "GCTestCase.h" +#import "GCRepository+Index.h" + +static NSString* _CommitSignature(GCCommit* commit) { + git_buf buffer = {0}; + int status = git_commit_header_field(&buffer, commit.private, "gpgsig"); + if (status != GIT_OK) { + git_buf_free(&buffer); + return nil; + } + NSString* signature = [[NSString alloc] initWithBytes:buffer.ptr length:buffer.size encoding:NSUTF8StringEncoding]; + git_buf_free(&buffer); + return signature; +} + +static BOOL _WriteLocalConfigOption(GCRepository* repository, NSString* variable, NSString* value) { + return [repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:variable withValue:value error:NULL]; +} + +static BOOL _CommitHasSSHSignature(GCCommit* commit) { + return [_CommitSignature(commit) containsString:@"BEGIN SSH SIGNATURE"]; +} + +static BOOL _ConfigureSSHSigningWithKeyPath(GCRepository* repository, NSString* keyPath) { + if (!_WriteLocalConfigOption(repository, @"commit.gpgsign", @"true")) { + return NO; + } + if (!_WriteLocalConfigOption(repository, @"gpg.format", @"ssh")) { + return NO; + } + return _WriteLocalConfigOption(repository, @"user.signingkey", keyPath); +} + +static NSString* _CreateFakeSSHSigner(NSString* directory, int exitStatus) { + NSString* path = [directory stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; + NSString* contents; + + if (exitStatus == 0) { + NSArray* lines = @[ + @"#!/bin/sh", + @"cat >/dev/null", + @"printf '%s\\n' '-----BEGIN SSH SIGNATURE-----' 'fake-signature' '-----END SSH SIGNATURE-----'", + @"" + ]; + contents = [lines componentsJoinedByString:@"\n"]; + } else { + NSArray* lines = @[ + @"#!/bin/sh", + @"cat >/dev/null", + @"echo signer failed >&2", + [NSString stringWithFormat:@"exit %i", exitStatus], + @"" + ]; + contents = [lines componentsJoinedByString:@"\n"]; + } + + if (![contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:NULL]) { + return nil; + } + NSDictionary* attributes = @{NSFilePosixPermissions : @(0755)}; + if (![[NSFileManager defaultManager] setAttributes:attributes ofItemAtPath:path error:NULL]) { + return nil; + } + return path; +} + +static GCCommit* _CreateCommitFromRepositoryIndex(GCRepository* repository, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_index* index = NULL; + git_tree* tree = NULL; + + index = [repository reloadRepositoryIndex:error]; + if (!index) { + goto cleanup; + } + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); + commit = GCCreateCommitFromTreeWithOptionalSignature(repository, + tree, + NULL, + 0, + NULL, + message, + error); + +cleanup: + git_tree_free(tree); + git_index_free(index); + return commit; +} + +@implementation GCEmptyRepositoryTests (GCCommitSigning) + +- (void)testCommitSigningLeavesCommitsUnsignedWhenDisabledOrUnsupported { + [self updateFileAtPath:@"unsigned.txt" withString:@"unsigned\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"unsigned.txt" error:NULL]); + + GCCommit* unsignedCommit = _CreateCommitFromRepositoryIndex(self.repository, @"Unsigned", NULL); + XCTAssertNotNil(unsignedCommit); + XCTAssertNil(_CommitSignature(unsignedCommit)); + + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"openpgp")); + [self updateFileAtPath:@"openpgp.txt" withString:@"openpgp\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"openpgp.txt" error:NULL]); + + GCCommit* openPGPCommit = _CreateCommitFromRepositoryIndex(self.repository, @"OpenPGP config remains unsigned", NULL); + XCTAssertNotNil(openPGPCommit); + XCTAssertNil(_CommitSignature(openPGPCommit)); +} + +- (void)testCommitSigningRequiresSSHKey { + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"ssh")); + [self updateFileAtPath:@"missing-key.txt" withString:@"missing key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"missing-key.txt" error:NULL]); + + NSError* error; + XCTAssertNil(_CreateCommitFromRepositoryIndex(self.repository, @"Missing key", &error)); + XCTAssertTrue([error.localizedDescription containsString:@"user.signingkey"]); +} + +- (void)testCommitSigningSupportsInlineKeyAndDefaultKeyCommand { + NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 0); + NSString* inlineKey = @"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeInlineKey test@example.com"; + NSString* defaultKeyCommand = @"printf 'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDefaultCommandKey test@example.com\\n'"; + + XCTAssertNotNil(signer); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"ssh")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.ssh.program", signer)); + + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"user.signingkey", inlineKey)); + [self updateFileAtPath:@"inline-key.txt" withString:@"inline key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"inline-key.txt" error:NULL]); + + GCCommit* inlineCommit = _CreateCommitFromRepositoryIndex(self.repository, @"Inline key", NULL); + XCTAssertNotNil(inlineCommit); + XCTAssertTrue(_CommitHasSSHSignature(inlineCommit)); + + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"user.signingkey", nil)); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.ssh.defaultKeyCommand", defaultKeyCommand)); + [self updateFileAtPath:@"default-key-command.txt" withString:@"default key command\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"default-key-command.txt" error:NULL]); + + GCCommit* defaultCommandCommit = _CreateCommitFromRepositoryIndex(self.repository, @"Default key command", NULL); + XCTAssertNotNil(defaultCommandCommit); + XCTAssertTrue(_CommitHasSSHSignature(defaultCommandCommit)); +} + +- (void)testCommitSigningSupportsKeyPath { + NSString* keyPath = [self.temporaryPath stringByAppendingPathComponent:@"signing_key"]; + GCTask* keygen = [[GCTask alloc] initWithExecutablePath:@"/usr/bin/ssh-keygen"]; + int status; + NSArray* keygenArguments = @[ @"-t", @"ed25519", @"-f", keyPath, @"-N", @"", @"-q" ]; + BOOL keygenSuccess = [keygen runWithArguments:keygenArguments stdin:nil stdout:NULL stderr:NULL exitStatus:&status error:NULL]; + XCTAssertTrue(keygenSuccess); + XCTAssertEqual(status, 0); + XCTAssertTrue(_ConfigureSSHSigningWithKeyPath(self.repository, keyPath)); + + [self updateFileAtPath:@"path-key.txt" withString:@"path key\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"path-key.txt" error:NULL]); + + GCCommit* commit = _CreateCommitFromRepositoryIndex(self.repository, @"Path key", NULL); + XCTAssertNotNil(commit); + XCTAssertTrue(_CommitHasSSHSignature(commit)); + + NSString* publicKey = [NSString stringWithContentsOfFile:[keyPath stringByAppendingString:@".pub"] encoding:NSUTF8StringEncoding error:NULL]; + NSString* allowedSignersPath = [self.temporaryPath stringByAppendingPathComponent:@"allowed_signers"]; + NSString* allowedSigners = [NSString stringWithFormat:@"bot@example.com %@", publicKey]; + XCTAssertTrue([allowedSigners writeToFile:allowedSignersPath atomically:YES encoding:NSUTF8StringEncoding error:NULL]); + + NSString* allowedSignersConfig = [NSString stringWithFormat:@"gpg.ssh.allowedSignersFile=%@", allowedSignersPath]; + NSString* verifyOutput = [self runGitCLTWithRepository:self.repository command:@"-c", allowedSignersConfig, @"verify-commit", commit.SHA1, nil]; + XCTAssertNotNil(verifyOutput); +} + +- (void)testCommitSigningFailsOnSignerFailure { + NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 7); + NSString* signingKey = @"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFailingSignerKey test@example.com"; + + XCTAssertNotNil(signer); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"commit.gpgsign", @"true")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.format", @"ssh")); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"gpg.ssh.program", signer)); + XCTAssertTrue(_WriteLocalConfigOption(self.repository, @"user.signingkey", signingKey)); + [self updateFileAtPath:@"failing-signer.txt" withString:@"failing signer\n"]; + XCTAssertTrue([self.repository addFileToIndex:@"failing-signer.txt" error:NULL]); + + NSError* error; + XCTAssertNil(_CreateCommitFromRepositoryIndex(self.repository, @"Failing signer", &error)); + XCTAssertTrue([error.localizedDescription containsString:@"non-zero status"]); +} + +@end diff --git a/GitUpKit/Core/GCRepository+HEAD-Tests.m b/GitUpKit/Core/GCRepository+HEAD-Tests.m index de382c2a..4fc65008 100644 --- a/GitUpKit/Core/GCRepository+HEAD-Tests.m +++ b/GitUpKit/Core/GCRepository+HEAD-Tests.m @@ -45,16 +45,6 @@ static BOOL _ConfigureSSHSigningWithKeyPath(GCRepository* repository, NSString* error:NULL]; } -static NSString* _CreateFakeSSHSigner(NSString* directory, int exitStatus) { - NSString* path = [directory stringByAppendingPathComponent:[[NSProcessInfo processInfo] globallyUniqueString]]; - NSString* contents = exitStatus == 0 ? @"#!/bin/sh\ncat >/dev/null\nprintf '%s\\n' '-----BEGIN SSH SIGNATURE-----' 'fake-signature' '-----END SSH SIGNATURE-----'\n" - : [NSString stringWithFormat:@"#!/bin/sh\ncat >/dev/null\necho signer failed >&2\nexit %i\n", exitStatus]; - return ([contents writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:NULL] && - [[NSFileManager defaultManager] setAttributes:@{NSFilePosixPermissions : @(0755)} ofItemAtPath:path error:NULL]) - ? path - : nil; -} - @implementation GCEmptyRepositoryTests (GCRepository_HEAD) - (void)testUnbornHEAD { @@ -68,69 +58,6 @@ - (void)testUnbornHEAD { XCTAssertFalse(self.repository.HEADUnborn); } -- (void)testUnsignedCommitWhenSigningDisabledOrUnsupported { - [self updateFileAtPath:@"unsigned.txt" withString:@"unsigned\n"]; - XCTAssertTrue([self.repository addFileToIndex:@"unsigned.txt" error:NULL]); - GCCommit* unsignedCommit = [self.repository createCommitFromHEADWithMessage:@"Unsigned" error:NULL]; - XCTAssertNotNil(unsignedCommit); - XCTAssertNil(_CommitSignature(unsignedCommit)); - - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"openpgp" error:NULL]); - [self updateFileAtPath:@"openpgp.txt" withString:@"openpgp\n"]; - XCTAssertTrue([self.repository addFileToIndex:@"openpgp.txt" error:NULL]); - GCCommit* openPGPCommit = [self.repository createCommitFromHEADWithMessage:@"OpenPGP config remains unsigned" error:NULL]; - XCTAssertNotNil(openPGPCommit); - XCTAssertNil(_CommitSignature(openPGPCommit)); -} - -- (void)testSSHSigningRequiresKey { - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"ssh" error:NULL]); - [self updateFileAtPath:@"missing-key.txt" withString:@"missing key\n"]; - XCTAssertTrue([self.repository addFileToIndex:@"missing-key.txt" error:NULL]); - NSError* error; - XCTAssertNil([self.repository createCommitFromHEADWithMessage:@"Missing key" error:&error]); - XCTAssertTrue([error.localizedDescription containsString:@"user.signingkey"]); -} - -- (void)testSSHSigningSupportsInlineKeyAndDefaultKeyCommand { - NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 0); - XCTAssertNotNil(signer); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"ssh" error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.ssh.program" withValue:signer error:NULL]); - - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"user.signingkey" withValue:@"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeInlineKey test@example.com" error:NULL]); - [self updateFileAtPath:@"inline-key.txt" withString:@"inline key\n"]; - XCTAssertTrue([self.repository addFileToIndex:@"inline-key.txt" error:NULL]); - GCCommit* inlineCommit = [self.repository createCommitFromHEADWithMessage:@"Inline key" error:NULL]; - XCTAssertNotNil(inlineCommit); - XCTAssertTrue([_CommitSignature(inlineCommit) containsString:@"BEGIN SSH SIGNATURE"]); - - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"user.signingkey" withValue:nil error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.ssh.defaultKeyCommand" withValue:@"printf 'key::ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDefaultCommandKey test@example.com\\n'" error:NULL]); - [self updateFileAtPath:@"default-key-command.txt" withString:@"default key command\n"]; - XCTAssertTrue([self.repository addFileToIndex:@"default-key-command.txt" error:NULL]); - GCCommit* defaultCommandCommit = [self.repository createCommitFromHEADWithMessage:@"Default key command" error:NULL]; - XCTAssertNotNil(defaultCommandCommit); - XCTAssertTrue([_CommitSignature(defaultCommandCommit) containsString:@"BEGIN SSH SIGNATURE"]); -} - -- (void)testSSHSignerFailureFailsCommit { - NSString* signer = _CreateFakeSSHSigner(self.temporaryPath, 7); - XCTAssertNotNil(signer); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"commit.gpgsign" withValue:@"true" error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.format" withValue:@"ssh" error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"gpg.ssh.program" withValue:signer error:NULL]); - XCTAssertTrue([self.repository writeConfigOptionForLevel:kGCConfigLevel_Local variable:@"user.signingkey" withValue:@"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFailingSignerKey test@example.com" error:NULL]); - [self updateFileAtPath:@"failing-signer.txt" withString:@"failing signer\n"]; - XCTAssertTrue([self.repository addFileToIndex:@"failing-signer.txt" error:NULL]); - NSError* error; - XCTAssertNil([self.repository createCommitFromHEADWithMessage:@"Failing signer" error:&error]); - XCTAssertTrue([error.localizedDescription containsString:@"non-zero status"]); -} - @end @implementation GCMultipleCommitsRepositoryTests (GCRepository_HEAD) diff --git a/GitUpKit/Core/GCRepository+HEAD.m b/GitUpKit/Core/GCRepository+HEAD.m index 3b27839e..dad1a684 100644 --- a/GitUpKit/Core/GCRepository+HEAD.m +++ b/GitUpKit/Core/GCRepository+HEAD.m @@ -130,6 +130,7 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N goto cleanup; } + // The signing helper creates a commit object from an explicit libgit2 parent list; this array covers both normal and merge commits. parents[0] = headCommit; parents[1] = parent.private; commit = _CreateCommitFromIndex(self, index, parents, (headCommit ? (parent ? 2 : 1) : 0), NULL, message, error); @@ -195,6 +196,7 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], headCommit, i); } } + // Amending preserves HEAD's original parents while creating a new commit object, so keep these parent commits alive through signing. commit = GCCreateCommitFromTreeWithOptionalSignature(self, tree, (const git_commit**)parentCommits, parentCount, git_commit_author(headCommit), message, error); if (commit == nil) { goto cleanup; diff --git a/GitUpKit/GitUpKit.xcodeproj/project.pbxproj b/GitUpKit/GitUpKit.xcodeproj/project.pbxproj index eedbbd8d..362a56cb 100644 --- a/GitUpKit/GitUpKit.xcodeproj/project.pbxproj +++ b/GitUpKit/GitUpKit.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ E21753681B91634C00BE234A /* GCCommitDatabase.m in Sources */ = {isa = PBXBuildFile; fileRef = E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; E21753691B91634C00BE234A /* GCDiff.m in Sources */ = {isa = PBXBuildFile; fileRef = E2B14B5D1A8A764400003E64 /* GCDiff.m */; }; E217536E1B91634C00BE234A /* GCCommitSigning.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753651B91634C00BE234A /* GCCommitSigning.m */; }; + E21753851B91635800BE234A /* GCCommitSigning-Tests.m in Sources */ = {isa = PBXBuildFile; fileRef = E21753861B91635800BE234A /* GCCommitSigning-Tests.m */; }; E217536A1B91634C00BE234A /* GCFoundation.m in Sources */ = {isa = PBXBuildFile; fileRef = E2790D411ACB1B1100965A98 /* GCFoundation.m */; }; E217536B1B91634C00BE234A /* GCFunctions.m in Sources */ = {isa = PBXBuildFile; fileRef = E21739F21A4FE39E00EC6777 /* GCFunctions.m */; }; E217536C1B91634C00BE234A /* GCHistory.m in Sources */ = {isa = PBXBuildFile; fileRef = E20F10F21A043E2100076AAC /* GCHistory.m */; settings = {COMPILER_FLAGS = "-fno-objc-arc"; }; }; @@ -412,6 +413,7 @@ E21739F11A4FE39E00EC6777 /* GCFunctions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCFunctions.h; sourceTree = ""; }; E21739F21A4FE39E00EC6777 /* GCFunctions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCFunctions.m; sourceTree = ""; }; E21753651B91634C00BE234A /* GCCommitSigning.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCCommitSigning.m; sourceTree = ""; }; + E21753861B91635800BE234A /* GCCommitSigning-Tests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "GCCommitSigning-Tests.m"; sourceTree = ""; }; E21739FC1A51FA6200EC6777 /* GCSubmodule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = GCSubmodule.h; sourceTree = ""; }; E21739FD1A51FA6200EC6777 /* GCSubmodule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GCSubmodule.m; sourceTree = ""; }; E217531B1B91613300BE234A /* GitUpKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GitUpKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -836,6 +838,7 @@ E2C338DD19F85C8600063D95 /* GCCommit.h */, E2C338DE19F85C8600063D95 /* GCCommit.m */, E21753651B91634C00BE234A /* GCCommitSigning.m */, + E21753861B91635800BE234A /* GCCommitSigning-Tests.m */, E2FEED441AEAA6AD00CBED80 /* GCCommitDatabase.h */, E2FEED451AEAA6AD00CBED80 /* GCCommitDatabase.m */, E2FEED481AEAA6B500CBED80 /* GCCommitDatabase-Tests.m */, @@ -1431,6 +1434,7 @@ E218A58F1A56706600DFF1DF /* GCRepository+Utilities.m in Sources */, E2C338F019F85C8600063D95 /* GCReference.m in Sources */, E259C2DD1A64FDD00079616B /* GCRepository+HEAD-Tests.m in Sources */, + E21753851B91635800BE234A /* GCCommitSigning-Tests.m in Sources */, E259C2C51A64C8EA0079616B /* GCTestCase.m in Sources */, E259C2E51A6624DC0079616B /* GCCommit-Tests.m in Sources */, E299D00C1A71F0E9005035F7 /* GCSQLiteRepository.m in Sources */, From 77f9890722bf3751edd03309234b67ee60a86991 Mon Sep 17 00:00:00 2001 From: Kristaps Austers Date: Wed, 3 Jun 2026 12:10:45 +0300 Subject: [PATCH 5/5] Hide amend parent handling in signing helper --- GitUpKit/Core/GCCommitSigning.m | 43 +++++++++++++++++++++++++++++-- GitUpKit/Core/GCPrivate.h | 1 + GitUpKit/Core/GCRepository+HEAD.m | 28 +------------------- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/GitUpKit/Core/GCCommitSigning.m b/GitUpKit/Core/GCCommitSigning.m index e68b22a3..afa5a978 100644 --- a/GitUpKit/Core/GCCommitSigning.m +++ b/GitUpKit/Core/GCCommitSigning.m @@ -238,6 +238,7 @@ static BOOL _LooksLikeInlineSSHKey(NSString* key) { git_signature* signature = NULL; git_commit* newCommit = NULL; NSData* cleanedMessage = nil; + const char* cleanedMessageBytes = NULL; #if !TARGET_OS_IPHONE git_buf commitBuffer = {0}; NSData* commitData = nil; @@ -249,13 +250,14 @@ static BOOL _LooksLikeInlineSSHKey(NSString* key) { CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_signature_default, &signature, repository.private); authorSignature = author ?: signature; cleanedMessage = GCCleanedUpCommitMessage(message); + cleanedMessageBytes = (const char*)cleanedMessage.bytes; #if !TARGET_OS_IPHONE if (!_ShouldSSHSignCommit(repository, &shouldSign, error)) { goto cleanup; } if (shouldSign) { - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_buffer, &commitBuffer, repository.private, authorSignature, signature, NULL, cleanedMessage.bytes, tree, count, parents); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_buffer, &commitBuffer, repository.private, authorSignature, signature, NULL, cleanedMessageBytes, tree, count, parents); commitData = [[NSData alloc] initWithBytes:commitBuffer.ptr length:commitBuffer.size]; sshSignature = _SSHSignatureForCommitBuffer(repository, commitData, error); if (!sshSignature) { @@ -264,7 +266,7 @@ static BOOL _LooksLikeInlineSSHKey(NSString* key) { CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create_with_signature, &oid, repository.private, commitBuffer.ptr, sshSignature.UTF8String, "gpgsig"); } else { #endif - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create, &oid, repository.private, NULL, authorSignature, signature, NULL, cleanedMessage.bytes, tree, count, parents); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_create, &oid, repository.private, NULL, authorSignature, signature, NULL, cleanedMessageBytes, tree, count, parents); #if !TARGET_OS_IPHONE } #endif @@ -280,3 +282,40 @@ static BOOL _LooksLikeInlineSSHKey(NSString* key) { git_signature_free(signature); return commit; } + +GCCommit* GCCreateCommitFromCommitWithIndexAndOptionalSignature(GCRepository* repository, git_commit* amendedCommit, git_index* index, NSString* message, NSError** error) { + GCCommit* commit = nil; + git_tree* tree = NULL; + git_commit** parentCommits = NULL; + unsigned int parentCount = 0; + const git_commit** parents = NULL; + + git_oid oid; + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, repository.private); + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, repository.private, &oid); + + parentCount = git_commit_parentcount(amendedCommit); + if (parentCount) { + parentCommits = (git_commit**)calloc(parentCount, sizeof(git_commit*)); + if (!parentCommits) { + GC_SET_GENERIC_ERROR(@"Unable to allocate commit parent list"); + goto cleanup; + } + for (unsigned int i = 0; i < parentCount; ++i) { + CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], amendedCommit, i); + } + } + parents = (const git_commit**)parentCommits; + + commit = GCCreateCommitFromTreeWithOptionalSignature(repository, tree, parents, parentCount, git_commit_author(amendedCommit), message, error); + +cleanup: + if (parentCommits) { + for (unsigned int i = 0; i < parentCount; ++i) { + git_commit_free(parentCommits[i]); + } + free(parentCommits); + } + git_tree_free(tree); + return commit; +} diff --git a/GitUpKit/Core/GCPrivate.h b/GitUpKit/Core/GCPrivate.h index 0b5db3bb..b915102f 100644 --- a/GitUpKit/Core/GCPrivate.h +++ b/GitUpKit/Core/GCPrivate.h @@ -119,6 +119,7 @@ extern BOOL GCGitOIDFromSHA1(NSString* sha1, git_oid* oid, NSError** error); extern BOOL GCGitOIDFromSHA1Prefix(NSString* prefix, git_oid* oid, NSError** error); extern NSData* GCCleanedUpCommitMessage(NSString* message); extern GCCommit* GCCreateCommitFromTreeWithOptionalSignature(GCRepository* repository, git_tree* tree, const git_commit** parents, NSUInteger count, const git_signature* author, NSString* message, NSError** error); +extern GCCommit* GCCreateCommitFromCommitWithIndexAndOptionalSignature(GCRepository* repository, git_commit* commit, git_index* index, NSString* message, NSError** error); extern NSString* GCUserFromSignature(const git_signature* signature); extern const void* GCOIDCopyCallBack(CFAllocatorRef allocator, const void* value); extern Boolean GCOIDEqualCallBack(const void* value1, const void* value2); diff --git a/GitUpKit/Core/GCRepository+HEAD.m b/GitUpKit/Core/GCRepository+HEAD.m index dad1a684..1c2288ef 100644 --- a/GitUpKit/Core/GCRepository+HEAD.m +++ b/GitUpKit/Core/GCRepository+HEAD.m @@ -130,7 +130,6 @@ - (GCCommit*)createCommitFromHEADAndOtherParent:(GCCommit*)parent withMessage:(N goto cleanup; } - // The signing helper creates a commit object from an explicit libgit2 parent list; this array covers both normal and merge commits. parents[0] = headCommit; parents[1] = parent.private; commit = _CreateCommitFromIndex(self, index, parents, (headCommit ? (parent ? 2 : 1) : 0), NULL, message, error); @@ -168,9 +167,6 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE git_reference* headReference = NULL; git_commit* headCommit = NULL; git_index* index = NULL; - git_tree* tree = NULL; - git_commit** parentCommits = NULL; - unsigned int parentCount = 0; NSString* reflogMessage; CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_repository_head, &headReference, self.private); // Returns a direct reference or GIT_EUNBORNBRANCH @@ -182,22 +178,7 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE goto cleanup; } - git_oid oid; - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_index_write_tree_to, &oid, index, self.private); - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_tree_lookup, &tree, self.private, &oid); - parentCount = git_commit_parentcount(headCommit); - if (parentCount) { - parentCommits = (git_commit**)calloc(parentCount, sizeof(git_commit*)); - if (!parentCommits) { - GC_SET_GENERIC_ERROR(@"Unable to allocate commit parent list"); - goto cleanup; - } - for (unsigned int i = 0; i < parentCount; ++i) { - CALL_LIBGIT2_FUNCTION_GOTO(cleanup, git_commit_parent, &parentCommits[i], headCommit, i); - } - } - // Amending preserves HEAD's original parents while creating a new commit object, so keep these parent commits alive through signing. - commit = GCCreateCommitFromTreeWithOptionalSignature(self, tree, (const git_commit**)parentCommits, parentCount, git_commit_author(headCommit), message, error); + commit = GCCreateCommitFromCommitWithIndexAndOptionalSignature(self, headCommit, index, message, error); if (commit == nil) { goto cleanup; } @@ -213,13 +194,6 @@ - (GCCommit*)createCommitByAmendingHEADWithMessage:(NSString*)message error:(NSE success = YES; cleanup: - if (parentCommits) { - for (unsigned int i = 0, count = headCommit ? git_commit_parentcount(headCommit) : 0; i < count; ++i) { - git_commit_free(parentCommits[i]); - } - free(parentCommits); - } - git_tree_free(tree); git_index_free(index); git_commit_free(headCommit); git_reference_free(headReference);