Add support for creating signed commits and signing commits during a rebase

This commit is contained in:
Michael Boulton 2020-08-10 16:57:46 +01:00
parent 462ebd83e0
commit c9e192b7e5
No known key found for this signature in database
GPG Key ID: 8A62CA0BE2E0197E
7 changed files with 329 additions and 9 deletions

View File

@ -40,6 +40,62 @@ func (c *Commit) RawMessage() string {
return ret return ret
} }
func (c *Commit) RawHeader() string {
ret := C.GoString(C.git_commit_raw_header(c.cast_ptr))
runtime.KeepAlive(c)
return ret
}
// ContentToSign returns the content that will be passed to a signing function for this commit
func (c *Commit) ContentToSign() string {
return c.RawHeader() + "\n" + c.RawMessage()
}
// CommitSigningCb defines a function type that takes some data to sign and returns (signature, signature_field, error)
type CommitSigningCb func(string) (string, string, error)
// WithSignatureUsing creates a new signed commit from this one using the given signing callback
func (c *Commit) WithSignatureUsing(f CommitSigningCb) (*Oid, error) {
signature, signatureField, err := f(c.ContentToSign())
if err != nil {
return nil, err
}
return c.WithSignature(signature, signatureField)
}
// WithSignature creates a new signed commit from the given signature and signature field
func (c *Commit) WithSignature(signature string, signatureField string) (*Oid, error) {
totalCommit := c.ContentToSign()
oid := new(Oid)
var csf *C.char = nil
if signatureField != "" {
csf = C.CString(signatureField)
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
ret := C.git_commit_create_with_signature(
oid.toC(),
c.Owner().ptr,
C.CString(totalCommit),
C.CString(signature),
csf,
)
runtime.KeepAlive(c)
runtime.KeepAlive(oid)
if ret < 0 {
return nil, MakeGitError(ret)
}
return oid, nil
}
func (c *Commit) ExtractSignature() (string, string, error) { func (c *Commit) ExtractSignature() (string, string, error) {
var c_signed C.git_buf var c_signed C.git_buf

View File

@ -45,7 +45,16 @@ func createBareTestRepo(t *testing.T) *Repository {
return repo return repo
} }
// commitOpts contains any extra options for creating commits in the seed repo
type commitOpts struct {
CommitSigningCb
}
func seedTestRepo(t *testing.T, repo *Repository) (*Oid, *Oid) { func seedTestRepo(t *testing.T, repo *Repository) (*Oid, *Oid) {
return seedTestRepoOpt(t, repo, commitOpts{})
}
func seedTestRepoOpt(t *testing.T, repo *Repository, opts commitOpts) (*Oid, *Oid) {
loc, err := time.LoadLocation("Europe/Berlin") loc, err := time.LoadLocation("Europe/Berlin")
checkFatal(t, err) checkFatal(t, err)
sig := &Signature{ sig := &Signature{
@ -69,6 +78,28 @@ func seedTestRepo(t *testing.T, repo *Repository) (*Oid, *Oid) {
commitId, err := repo.CreateCommit("HEAD", sig, sig, message, tree) commitId, err := repo.CreateCommit("HEAD", sig, sig, message, tree)
checkFatal(t, err) checkFatal(t, err)
if opts.CommitSigningCb != nil {
commit, err := repo.LookupCommit(commitId)
checkFatal(t, err)
signature, signatureField, err := opts.CommitSigningCb(commit.ContentToSign())
checkFatal(t, err)
oid, err := commit.WithSignature(signature, signatureField)
checkFatal(t, err)
newCommit, err := repo.LookupCommit(oid)
checkFatal(t, err)
head, err := repo.Head()
checkFatal(t, err)
_, err = repo.References.Create(
head.Name(),
newCommit.Id(),
true,
"repoint to signed commit",
)
checkFatal(t, err)
}
return commitId, treeId return commitId, treeId
} }

2
go.mod
View File

@ -1,3 +1,5 @@
module github.com/libgit2/git2go/v30 module github.com/libgit2/git2go/v30
go 1.13 go 1.13
require golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de

7
go.sum Normal file
View File

@ -0,0 +1,7 @@
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de h1:ikNHVSjEfnvz6sxdSPCaPt572qowuyMDMJLLm3Db3ig=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -2,6 +2,8 @@ package git
/* /*
#include <git2.h> #include <git2.h>
extern void _go_git_populate_commit_sign_cb(git_rebase_options *opts);
*/ */
import "C" import "C"
import ( import (
@ -69,6 +71,59 @@ func newRebaseOperationFromC(c *C.git_rebase_operation) *RebaseOperation {
return operation return operation
} }
//export commitSignCallback
func commitSignCallback(_signature *C.git_buf, _signature_field *C.git_buf, _commit_content *C.char, _payload unsafe.Pointer) C.int {
opts, ok := pointerHandles.Get(_payload).(RebaseOptions)
if !ok {
panic("invalid sign payload")
}
if opts.SigningCallback == nil {
return C.GIT_PASSTHROUGH
}
commitContent := C.GoString(_commit_content)
signature, signatureField, err := opts.SigningCallback(commitContent)
if err != nil {
return C.int(-1)
}
fillBuf := func(bufData string, buf *C.git_buf) error {
clen := C.size_t(len(bufData))
cstr := unsafe.Pointer(C.CString(bufData))
// over-assign by a byte (see below)
if int(C.git_buf_grow(buf, clen+1)) != 0 {
return errors.New("could not grow buffer")
}
if int(C.git_buf_set(buf, cstr, clen)) != 0 {
return errors.New("could not set buffer")
}
// git_buf_set sets 'size' to the 'size' of the buffer to 'clen', but we want it to be clen+1 because after returning it asserts that the buffer ends with a null byte, which Go strings don't
// This avoids having to convert the string to a []byte, then adding a null byte, then doing another copy
buf.size += 1
return nil
}
if signatureField != "" {
err := fillBuf(signatureField, _signature_field)
if err != nil {
return C.int(-1)
}
}
err = fillBuf(signature, _signature)
if err != nil {
return C.int(-1)
}
return C.GIT_OK
}
// RebaseOptions are used to tell the rebase machinery how to operate // RebaseOptions are used to tell the rebase machinery how to operate
type RebaseOptions struct { type RebaseOptions struct {
Version uint Version uint
@ -77,6 +132,7 @@ type RebaseOptions struct {
RewriteNotesRef string RewriteNotesRef string
MergeOptions MergeOptions MergeOptions MergeOptions
CheckoutOptions CheckoutOpts CheckoutOptions CheckoutOpts
SigningCallback CommitSigningCb
} }
// DefaultRebaseOptions returns a RebaseOptions with default values. // DefaultRebaseOptions returns a RebaseOptions with default values.
@ -101,6 +157,7 @@ func rebaseOptionsFromC(opts *C.git_rebase_options) RebaseOptions {
RewriteNotesRef: C.GoString(opts.rewrite_notes_ref), RewriteNotesRef: C.GoString(opts.rewrite_notes_ref),
MergeOptions: mergeOptionsFromC(&opts.merge_options), MergeOptions: mergeOptionsFromC(&opts.merge_options),
CheckoutOptions: checkoutOptionsFromC(&opts.checkout_options), CheckoutOptions: checkoutOptionsFromC(&opts.checkout_options),
// TODO: is it possible to take the callback from C and have it be meaningful in Go? Would we ever want to do it?
} }
} }
@ -108,7 +165,7 @@ func (ro *RebaseOptions) toC() *C.git_rebase_options {
if ro == nil { if ro == nil {
return nil return nil
} }
return &C.git_rebase_options{ opts := &C.git_rebase_options{
version: C.uint(ro.Version), version: C.uint(ro.Version),
quiet: C.int(ro.Quiet), quiet: C.int(ro.Quiet),
inmemory: C.int(ro.InMemory), inmemory: C.int(ro.InMemory),
@ -116,6 +173,13 @@ func (ro *RebaseOptions) toC() *C.git_rebase_options {
merge_options: *ro.MergeOptions.toC(), merge_options: *ro.MergeOptions.toC(),
checkout_options: *ro.CheckoutOptions.toC(), checkout_options: *ro.CheckoutOptions.toC(),
} }
if ro.SigningCallback != nil {
C._go_git_populate_commit_sign_cb(opts)
opts.payload = pointerHandles.Track(*ro)
}
return opts
} }
func mapEmptyStringToNull(ref string) *C.char { func mapEmptyStringToNull(ref string) *C.char {

View File

@ -1,10 +1,15 @@
package git package git
import ( import (
"bytes"
"errors" "errors"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
"golang.org/x/crypto/openpgp"
"golang.org/x/crypto/openpgp/packet"
) )
// Tests // Tests
@ -48,7 +53,7 @@ func TestRebaseAbort(t *testing.T) {
assertStringList(t, expectedHistory, actualHistory) assertStringList(t, expectedHistory, actualHistory)
// Rebase onto master // Rebase onto master
rebase, err := performRebaseOnto(repo, "master") rebase, err := performRebaseOnto(repo, "master", nil)
checkFatal(t, err) checkFatal(t, err)
defer rebase.Free() defer rebase.Free()
@ -104,7 +109,7 @@ func TestRebaseNoConflicts(t *testing.T) {
} }
// Rebase onto master // Rebase onto master
rebase, err := performRebaseOnto(repo, "master") rebase, err := performRebaseOnto(repo, "master", nil)
checkFatal(t, err) checkFatal(t, err)
defer rebase.Free() defer rebase.Free()
@ -130,11 +135,131 @@ func TestRebaseNoConflicts(t *testing.T) {
actualHistory, err := commitMsgsList(repo) actualHistory, err := commitMsgsList(repo)
checkFatal(t, err) checkFatal(t, err)
assertStringList(t, expectedHistory, actualHistory) assertStringList(t, expectedHistory, actualHistory)
}
func TestRebaseGpgSigned(t *testing.T) {
// TEST DATA
entity, err := openpgp.NewEntity("Namey mcnameface", "test comment", "test@example.com", nil)
checkFatal(t, err)
opts, err := DefaultRebaseOptions()
checkFatal(t, err)
signCommitContent := func(commitContent string) (string, string, error) {
cipherText := new(bytes.Buffer)
err := openpgp.ArmoredDetachSignText(cipherText, entity, strings.NewReader(commitContent), &packet.Config{})
if err != nil {
return "", "", errors.New("error signing payload")
}
return cipherText.String(), "", nil
}
opts.SigningCallback = signCommitContent
commitOpts := commitOpts{
CommitSigningCb: signCommitContent,
}
// Inputs
branchName := "emile"
masterCommit := "something"
emileCommits := []string{
"fou",
"barre",
"ouich",
}
// Outputs
expectedHistory := []string{
"Test rebase, Baby! " + emileCommits[2],
"Test rebase, Baby! " + emileCommits[1],
"Test rebase, Baby! " + emileCommits[0],
"Test rebase, Baby! " + masterCommit,
"This is a commit\n",
}
// TEST
repo := createTestRepo(t)
defer cleanupTestRepo(t, repo)
seedTestRepoOpt(t, repo, commitOpts)
// Try to open existing rebase
_, err = repo.OpenRebase(nil)
if err == nil {
t.Fatal("Did not expect to find a rebase in progress")
}
// Setup a repo with 2 branches and a different tree
err = setupRepoForRebaseOpts(repo, masterCommit, branchName, commitOpts)
checkFatal(t, err)
// Create several commits in emile
for _, commit := range emileCommits {
_, err = commitSomethingOpts(repo, commit, commit, commitOpts)
checkFatal(t, err)
}
// Rebase onto master
rebase, err := performRebaseOnto(repo, "master", &opts)
checkFatal(t, err)
defer rebase.Free()
// Finish the rebase properly
err = rebase.Finish()
checkFatal(t, err)
// Check history is in correct order
actualHistory, err := commitMsgsList(repo)
checkFatal(t, err)
assertStringList(t, expectedHistory, actualHistory)
checkAllCommitsSigned(t, entity, repo)
}
func checkAllCommitsSigned(t *testing.T, entity *openpgp.Entity, repo *Repository) {
head, err := headCommit(repo)
checkFatal(t, err)
defer head.Free()
parent := head.Parent(0)
defer parent.Free()
err = checkCommitSigned(t, entity, parent)
checkFatal(t, err)
for parent.ParentCount() != 0 {
parent = parent.Parent(0)
defer parent.Free()
err = checkCommitSigned(t, entity, parent)
checkFatal(t, err)
}
}
func checkCommitSigned(t *testing.T, entity *openpgp.Entity, commit *Commit) error {
signature, signedData, err := commit.ExtractSignature()
if err != nil {
t.Logf("No signature on commit\n%s", commit.RawHeader()+"\n"+commit.RawMessage())
return err
}
_, err = openpgp.CheckArmoredDetachedSignature(openpgp.EntityList{entity}, strings.NewReader(signedData), bytes.NewBufferString(signature))
if err != nil {
t.Logf("Commit is not signed correctly\n%s", commit.RawHeader()+"\n"+commit.RawMessage())
return err
}
return nil
} }
// Utils // Utils
func setupRepoForRebase(repo *Repository, masterCommit, branchName string) error { func setupRepoForRebase(repo *Repository, masterCommit, branchName string) error {
return setupRepoForRebaseOpts(repo, masterCommit, branchName, commitOpts{})
}
func setupRepoForRebaseOpts(repo *Repository, masterCommit, branchName string, opts commitOpts) error {
// Create a new branch from master // Create a new branch from master
err := createBranch(repo, branchName) err := createBranch(repo, branchName)
if err != nil { if err != nil {
@ -142,7 +267,7 @@ func setupRepoForRebase(repo *Repository, masterCommit, branchName string) error
} }
// Create a commit in master // Create a commit in master
_, err = commitSomething(repo, masterCommit, masterCommit) _, err = commitSomethingOpts(repo, masterCommit, masterCommit, opts)
if err != nil { if err != nil {
return err return err
} }
@ -161,7 +286,7 @@ func setupRepoForRebase(repo *Repository, masterCommit, branchName string) error
return nil return nil
} }
func performRebaseOnto(repo *Repository, branch string) (*Rebase, error) { func performRebaseOnto(repo *Repository, branch string, opts *RebaseOptions) (*Rebase, error) {
master, err := repo.LookupBranch(branch, BranchLocal) master, err := repo.LookupBranch(branch, BranchLocal)
if err != nil { if err != nil {
return nil, err return nil, err
@ -175,7 +300,7 @@ func performRebaseOnto(repo *Repository, branch string) (*Rebase, error) {
defer onto.Free() defer onto.Free()
// Init rebase // Init rebase
rebase, err := repo.InitRebase(nil, nil, onto, nil) rebase, err := repo.InitRebase(nil, nil, onto, opts)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -277,6 +402,10 @@ func headTree(repo *Repository) (*Tree, error) {
} }
func commitSomething(repo *Repository, something, content string) (*Oid, error) { func commitSomething(repo *Repository, something, content string) (*Oid, error) {
return commitSomethingOpts(repo, something, content, commitOpts{})
}
func commitSomethingOpts(repo *Repository, something, content string, commitOpts commitOpts) (*Oid, error) {
headCommit, err := headCommit(repo) headCommit, err := headCommit(repo)
if err != nil { if err != nil {
return nil, err return nil, err
@ -315,14 +444,40 @@ func commitSomething(repo *Repository, something, content string) (*Oid, error)
} }
defer newTree.Free() defer newTree.Free()
if err != nil {
return nil, err
}
commit, err := repo.CreateCommit("HEAD", signature(), signature(), "Test rebase, Baby! "+something, newTree, headCommit) commit, err := repo.CreateCommit("HEAD", signature(), signature(), "Test rebase, Baby! "+something, newTree, headCommit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if commitOpts.CommitSigningCb != nil {
commit, err := repo.LookupCommit(commit)
if err != nil {
return nil, err
}
oid, err := commit.WithSignatureUsing(commitOpts.CommitSigningCb)
if err != nil {
return nil, err
}
newCommit, err := repo.LookupCommit(oid)
if err != nil {
return nil, err
}
head, err := repo.Head()
if err != nil {
return nil, err
}
_, err = repo.References.Create(
head.Name(),
newCommit.Id(),
true,
"repoint to signed commit",
)
if err != nil {
return nil, err
}
}
opts := &CheckoutOpts{ opts := &CheckoutOpts{
Strategy: CheckoutRemoveUntracked | CheckoutForce, Strategy: CheckoutRemoveUntracked | CheckoutForce,
} }

View File

@ -6,6 +6,11 @@
typedef int (*gogit_submodule_cbk)(git_submodule *sm, const char *name, void *payload); typedef int (*gogit_submodule_cbk)(git_submodule *sm, const char *name, void *payload);
void _go_git_populate_commit_sign_cb(git_rebase_options *opts)
{
opts->signing_cb = (git_commit_signing_cb)commitSignCallback;
}
void _go_git_populate_remote_cb(git_clone_options *opts) void _go_git_populate_remote_cb(git_clone_options *opts)
{ {
opts->remote_cb = (git_remote_create_cb)remoteCreateCallback; opts->remote_cb = (git_remote_create_cb)remoteCreateCallback;