Skip to content

feat: add commit signing #1142

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/recipes/git-commit.rst
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,61 @@ The rest is the same:
>>> tree = index.write_tree()
>>> repo.create_commit(ref, author, committer, message, tree, parents)


----------------------------------------------------------------------
Signing a commit
----------------------------------------------------------------------

Add everything, and commit with a GPG signature:

.. code-block:: bash

$ git add .
$ git commit -S -m "Signed commit"

.. code-block:: python

>>> index = repo.index
>>> index.add_all()
>>> index.write()
>>> author = Signature('Alice Author', '[email protected]')
>>> committer = Signature('Cecil Committer', '[email protected]')
>>> message = "Signed commit"
>>> tree = index.write_tree()
>>> parents = []
>>> commit_string = repo.create_commit_string(
>>> author, committer, message, tree, parents
>>> )

The ``commit_string`` can then be signed by a third party library:

.. code-block:: python
>>> gpg = YourGPGToolHere()
>>> signed_commit = gpg.sign(
>>> commit_string,
>>> passphrase='secret',
>>> detach=True,
>>> )

.. note::
The commit signature should resemble:

.. code-block:: none
>>> -----BEGIN PGP SIGNATURE-----
>>>
>>> < base64 encoded hash here >
>>> -----END PGP SIGNATURE-----

The signed commit can then be added to the branch:

.. code-block:: python

>>> commit = repo.create_commit_with_signature(
>>> commit_string, signed_commit.data.decode('utf-8')
>>> )
>>> repo.head.set_target(commit)


----------------------------------------------------------------------
References
----------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion pygit2/_pygit2.pyi
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import Iterator
from typing import Iterator, Optional
from io import IOBase
from . import Index, Submodule

Expand Down Expand Up @@ -459,6 +459,8 @@ class Repository:
def create_blob_fromworkdir(self, path: str) -> Oid: ...
def create_branch(self, name: str, commit: Commit, force = False) -> Branch: ...
def create_commit(self, reference_name: str, author: Signature, committer: Signature, message: str, tree: _OidArg, parents: list[_OidArg], encoding: str = ...) -> Oid: ...
def create_commit_string(self, author: Signature, committer: Signature, message: str, tree: _OidArg, parents: list[_OidArg], encoding: str = ...) -> Oid: ...
def create_commit_with_signature(self, content: str, signature: str, signature_field: Optional[str] = None) -> Oid: ...
def create_note(self, message: str, author: Signature, committer: Signature, annotated_id: str, ref: str = "refs/notes/commits", force: bool = False) -> Oid: ...
def create_reference_direct(self, name: str, target: _OidArg, force: bool, message: str = None) -> Reference: ...
def create_reference_symbolic(self, name: str, target: str, force: bool, message: str = None) -> Reference: ...
Expand Down
111 changes: 111 additions & 0 deletions src/repository.c
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,115 @@ Repository_create_commit(Repository *self, PyObject *args)
return py_result;
}

PyDoc_STRVAR(Repository_create_commit_string__doc__,
"create_commit_string(author: Signature, committer: Signature, message: bytes | str, tree: Oid, parents: list[Oid][, encoding: str]) -> str\n"
"\n"
"Create a new commit but return it as a string.");

PyObject *
Repository_create_commit_string(Repository *self, PyObject *args)
{
Signature *py_author, *py_committer;
PyObject *py_oid, *py_message, *py_parents, *py_parent;
PyObject *str;
char *encoding = NULL;
git_oid oid;
git_tree *tree = NULL;
int parent_count;
git_commit **parents = NULL;
git_buf buf = { 0 };
int i = 0;

if (!PyArg_ParseTuple(args, "O!O!OOO!|s",
&SignatureType, &py_author,
&SignatureType, &py_committer,
&py_message,
&py_oid,
&PyList_Type, &py_parents,
&encoding))
return NULL;

size_t len = py_oid_to_git_oid(py_oid, &oid);
if (len == 0)
return NULL;

PyObject *tmessage;
const char *message = pgit_borrow_encoding(py_message, encoding, NULL, &tmessage);
if (message == NULL)
return NULL;

int err = git_tree_lookup_prefix(&tree, self->repo, &oid, len);
if (err < 0) {
Error_set(err);
goto out;
}

parent_count = (int)PyList_Size(py_parents);
parents = malloc(parent_count * sizeof(git_commit*));
if (parents == NULL) {
PyErr_SetNone(PyExc_MemoryError);
goto out;
}
for (; i < parent_count; i++) {
py_parent = PyList_GET_ITEM(py_parents, i);
len = py_oid_to_git_oid(py_parent, &oid);
if (len == 0)
goto out;
err = git_commit_lookup_prefix(&parents[i], self->repo, &oid, len);
if (err < 0) {
Error_set(err);
goto out;
}
}

err = git_commit_create_buffer(&buf, self->repo,
py_author->signature, py_committer->signature,
encoding, message, tree, parent_count,
(const git_commit**)parents);
if (err < 0) {
Error_set(err);
goto out;
}

str = to_unicode_n(buf.ptr, buf.size, NULL, NULL);
git_buf_dispose(&buf);

out:
Py_DECREF(tmessage);
git_tree_free(tree);
while (i > 0) {
i--;
git_commit_free(parents[i]);
}
free(parents);
return str;
}

PyDoc_STRVAR(Repository_create_commit_with_signature__doc__,
"create_commit_with_signature(content: str, signature: str, field_name: str) -> Oid\n"
"\n"
"Create a new signed commit object, return its oid.");

PyObject *
Repository_create_commit_with_signature(Repository *self, PyObject *args)
{
git_oid oid;
char *content, *signature;
char *signature_field = NULL;

if (!PyArg_ParseTuple(args, "ss|s", &content, &signature, &signature_field))
return NULL;

int err = git_commit_create_with_signature(&oid, self->repo, content,
signature, signature_field);

if (err < 0) {
Error_set(err);
return NULL;
}

return git_oid_to_python(&oid);
}

PyDoc_STRVAR(Repository_create_tag__doc__,
"create_tag(name: str, oid: Oid, type: int, tagger: Signature[, message: str]) -> Oid\n"
Expand Down Expand Up @@ -2277,6 +2386,8 @@ PyMethodDef Repository_methods[] = {
METHOD(Repository, create_blob_fromdisk, METH_VARARGS),
METHOD(Repository, create_blob_fromiobase, METH_O),
METHOD(Repository, create_commit, METH_VARARGS),
METHOD(Repository, create_commit_string, METH_VARARGS),
METHOD(Repository, create_commit_with_signature, METH_VARARGS),
METHOD(Repository, create_tag, METH_VARARGS),
METHOD(Repository, TreeBuilder, METH_VARARGS),
METHOD(Repository, walk, METH_VARARGS),
Expand Down
2 changes: 2 additions & 0 deletions src/repository.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ PyObject* Repository_walk(Repository *self, PyObject *args);
PyObject* Repository_create_blob(Repository *self, PyObject *args);
PyObject* Repository_create_blob_fromdisk(Repository *self, PyObject *args);
PyObject* Repository_create_commit(Repository *self, PyObject *args);
PyObject* Repository_create_commit_string(Repository *self, PyObject *args);
PyObject* Repository_create_commit_with_signature(Repository *self, PyObject *args);
PyObject* Repository_create_tag(Repository *self, PyObject *args);
PyObject* Repository_create_branch(Repository *self, PyObject *args);
PyObject* Repository_listall_references(Repository *self, PyObject *args);
Expand Down
7 changes: 7 additions & 0 deletions test/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def emptyrepo(tmp_path):
with utils.TemporaryRepository('emptyrepo.zip', tmp_path) as path:
yield pygit2.Repository(path)


@pytest.fixture
def encodingrepo(tmp_path):
with utils.TemporaryRepository('encoding.zip', tmp_path) as path:
Expand Down Expand Up @@ -81,3 +82,9 @@ def testrepo_path(tmp_path):
def testrepopacked(tmp_path):
with utils.TemporaryRepository('testrepopacked.zip', tmp_path) as path:
yield pygit2.Repository(path)


@pytest.fixture
def gpgsigned(tmp_path):
with utils.TemporaryRepository('gpgsigned.zip', tmp_path) as path:
yield pygit2.Repository(path)
Binary file modified test/data/gpgsigned.zip
Binary file not shown.
137 changes: 104 additions & 33 deletions test/test_commit_gpg.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright 2010-2021 The pygit2 contributors
# Copyright 2010-2022 The pygit2 contributors
#
# This file is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License, version 2,
Expand All @@ -23,49 +23,120 @@
# the Free Software Foundation, 51 Franklin Street, Fifth Floor,
# Boston, MA 02110-1301, USA.

import pygit2
import pytest
from pygit2 import GIT_OBJ_COMMIT, Oid, Signature

from . import utils
content = """\
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
parent 8496071c1b46c854b31185ea97743be6a8774479
author Ben Burkert <[email protected]> 1358451456 -0800
committer Ben Burkert <[email protected]> 1358451456 -0800

a simple commit which works\
"""

@pytest.fixture
def repo(tmp_path):
with utils.TemporaryRepository('gpgsigned.zip', tmp_path) as path:
yield pygit2.Repository(path)
gpgsig = """\
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.12 (Darwin)

iQIcBAABAgAGBQJQ+FMIAAoJEH+LfPdZDSs1e3EQAJMjhqjWF+WkGLHju7pTw2al
o6IoMAhv0Z/LHlWhzBd9e7JeCnanRt12bAU7yvYp9+Z+z+dbwqLwDoFp8LVuigl8
JGLcnwiUW3rSvhjdCp9irdb4+bhKUnKUzSdsR2CK4/hC0N2i/HOvMYX+BRsvqweq
AsAkA6dAWh+gAfedrBUkCTGhlNYoetjdakWqlGL1TiKAefEZrtA1TpPkGn92vbLq
SphFRUY9hVn1ZBWrT3hEpvAIcZag3rTOiRVT1X1flj8B2vGCEr3RrcwOIZikpdaW
who/X3xh/DGbI2RbuxmmJpxxP/8dsVchRJJzBwG+yhwU/iN3MlV2c5D69tls/Dok
6VbyU4lm/ae0y3yR83D9dUlkycOnmmlBAHKIZ9qUts9X7mWJf0+yy2QxJVpjaTGG
cmnQKKPeNIhGJk2ENnnnzjEve7L7YJQF6itbx5VCOcsGh3Ocb3YR7DMdWjt7f8pu
c6j+q1rP7EpE2afUN/geSlp5i3x8aXZPDj67jImbVCE/Q1X9voCtyzGJH7MXR0N9
ZpRF8yzveRfMH8bwAJjSOGAFF5XkcR/RNY95o+J+QcgBLdX48h+ZdNmUf6jqlu3J
7KmTXXQcOVpN6dD3CmRFsbjq+x6RHwa8u1iGn+oIkX908r97ckfB/kHKH7ZdXIJc
cpxtDQQMGYFpXK/71stq
=ozeK
-----END PGP SIGNATURE-----\
"""

def test_get_gpg_signature_when_signed(repo):
signed_hash = 'a00b212d5455ad8c4c1779f778c7d2a81bb5da23'
expected_signature = (
'-----BEGIN PGP SIGNATURE-----\n\n'
'iQFGBAABCgAwFiEEQZu9JtePgJbDk7VC0+mlK74z13oFAlpzXykSHG1hcmtAbWFy\n'
'a2FkYW1zLm1lAAoJENPppSu+M9d6FRoIAJXeQRRT1V47nnHITiel6426loYkeij7\n'
'66doGNIyll95H92SwH4LAjPyEEByIG1VsA6NztzUoNgnEvAXI0iAz3LyI7N16M4b\n'
'dPDkC72pp8tu280H5Qt5b2V5hmlKKSgtOS5iNhdU/FbWVS8MlHsqzQTZfoTdi6ch\n'
'KWUsjzudVd3F/H/AU+1Jsxt8Iz/oK4T/puUQLnJZKjKlljGP994FA3JIpnZpZmbG\n'
'FybYJEDXnng7uhx3Fz/Mo3KBJoQfAExTtaToY0n0hSjOe6GN9rEsRSMK3mWdysf2\n'
'wOdtYMMcT16hG5tAwnD/myZ4rIIpyZJ/9mjymdUsj6UKf7D+vJuqfsI=\n=IyYy\n'
'-----END PGP SIGNATURE-----'
).encode('ascii')
gpgsig_content = """\
tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904
parent 8496071c1b46c854b31185ea97743be6a8774479
author Ben Burkert <[email protected]> 1358451456 -0800
committer Ben Burkert <[email protected]> 1358451456 -0800
gpgsig -----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.12 (Darwin)

iQIcBAABAgAGBQJQ+FMIAAoJEH+LfPdZDSs1e3EQAJMjhqjWF+WkGLHju7pTw2al
o6IoMAhv0Z/LHlWhzBd9e7JeCnanRt12bAU7yvYp9+Z+z+dbwqLwDoFp8LVuigl8
JGLcnwiUW3rSvhjdCp9irdb4+bhKUnKUzSdsR2CK4/hC0N2i/HOvMYX+BRsvqweq
AsAkA6dAWh+gAfedrBUkCTGhlNYoetjdakWqlGL1TiKAefEZrtA1TpPkGn92vbLq
SphFRUY9hVn1ZBWrT3hEpvAIcZag3rTOiRVT1X1flj8B2vGCEr3RrcwOIZikpdaW
who/X3xh/DGbI2RbuxmmJpxxP/8dsVchRJJzBwG+yhwU/iN3MlV2c5D69tls/Dok
6VbyU4lm/ae0y3yR83D9dUlkycOnmmlBAHKIZ9qUts9X7mWJf0+yy2QxJVpjaTGG
cmnQKKPeNIhGJk2ENnnnzjEve7L7YJQF6itbx5VCOcsGh3Ocb3YR7DMdWjt7f8pu
c6j+q1rP7EpE2afUN/geSlp5i3x8aXZPDj67jImbVCE/Q1X9voCtyzGJH7MXR0N9
ZpRF8yzveRfMH8bwAJjSOGAFF5XkcR/RNY95o+J+QcgBLdX48h+ZdNmUf6jqlu3J
7KmTXXQcOVpN6dD3CmRFsbjq+x6RHwa8u1iGn+oIkX908r97ckfB/kHKH7ZdXIJc
cpxtDQQMGYFpXK/71stq
=ozeK
-----END PGP SIGNATURE-----

expected_payload = (
'tree c36c20831e43e5984c672a714661870b67ab1d95\nauthor Mark Adams '
'<[email protected]> 1517510299 -0600\ncommitter Mark Adams <ma'
'[email protected]> 1517510441 -0600\n\nMaking a GPG signed commi'
't\n'
).encode('ascii')
a simple commit which works\
"""
# NOTE: ^^^ mind the gap (space must exist after GnuPG header) ^^^
# XXX: seems macos wants the space while linux does not

commit = repo.get(signed_hash)

def test_commit_signing(gpgsigned):
repo = gpgsigned
message = "a simple commit which works"
author = Signature(
name="Ben Burkert",
email="[email protected]",
time=1358451456,
offset=-480,
)
committer = Signature(
name="Ben Burkert",
email="[email protected]",
time=1358451456,
offset=-480,
)
tree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
parents = ["8496071c1b46c854b31185ea97743be6a8774479"]

# create commit string
commit_string = repo.create_commit_string(
author, committer, message, tree, parents
)
assert commit_string == content

# create/retrieve signed commit
oid = repo.create_commit_with_signature(content, gpgsig)
commit = repo.get(oid)
signature, payload = commit.gpg_signature

assert signature == expected_signature
assert payload == expected_payload
# validate signed commit
assert content == payload.decode("utf-8")
assert gpgsig == signature.decode("utf-8")
assert gpgsig_content == commit.read_raw().decode("utf-8")

# perform sanity checks
assert GIT_OBJ_COMMIT == commit.type
assert "6569fdf71dbd99081891154641869c537784a3ba" == commit.hex
assert commit.message_encoding is None
assert message == commit.message
assert 1358451456 == commit.commit_time
assert committer == commit.committer
assert author == commit.author
assert tree == commit.tree.hex
assert Oid(hex=tree) == commit.tree_id
assert 1 == len(commit.parents)
assert parents[0] == commit.parents[0].hex
assert Oid(hex=parents[0]) == commit.parent_ids[0]


def test_get_gpg_signature_when_unsigned(gpgsigned):
unhash = "5b5b025afb0b4c913b4c338a42934a3863bf3644"

def test_get_gpg_signature_when_unsigned(repo):
unsigned_hash = 'a84938d1d885e80dae24b86b06621cec47ff6edd'
commit = repo.get(unsigned_hash)
repo = gpgsigned
commit = repo.get(unhash)
signature, payload = commit.gpg_signature

assert signature is None
Expand Down