Merge branch 'cc/fast-import-export-signature-names'

Clean up the way how signature on commit objects are exported to
and imported from fast-import stream.

* cc/fast-import-export-signature-names:
  fast-(import|export): improve on commit signature output format
maint
Junio C Hamano 2025-07-23 15:45:16 -07:00
commit 5216bcbc84
7 changed files with 312 additions and 44 deletions

View File

@ -50,6 +50,23 @@ resulting tag will have an invalid signature.
is the same as how earlier versions of this command without
this option behaved.
+
When exported, a signature starts with:
+
gpgsig <git-hash-algo> <signature-format>
+
where <git-hash-algo> is the Git object hash so either "sha1" or
"sha256", and <signature-format> is the signature type, so "openpgp",
"x509", "ssh" or "unknown".
+
For example, an OpenPGP signature on a SHA-1 commit starts with
`gpgsig sha1 openpgp`, while an SSH signature on a SHA-256 commit
starts with `gpgsig sha256 ssh`.
+
While all the signatures of a commit are exported, an importer may
choose to accept only some of them. For example
linkgit:git-fast-import[1] currently stores at most one signature per
Git hash algorithm in each commit.
+
NOTE: This is highly experimental and the format of the data stream may
change in the future without compatibility guarantees.


View File

@ -445,7 +445,7 @@ one).
original-oid?
('author' (SP <name>)? SP LT <email> GT SP <when> LF)?
'committer' (SP <name>)? SP LT <email> GT SP <when> LF
('gpgsig' SP <alg> LF data)?
('gpgsig' SP <algo> SP <format> LF data)?
('encoding' SP <encoding> LF)?
data
('from' SP <commit-ish> LF)?
@ -518,13 +518,39 @@ their syntax.
^^^^^^^^

The optional `gpgsig` command is used to include a PGP/GPG signature
that signs the commit data.
or other cryptographic signature that signs the commit data.

Here <alg> specifies which hashing algorithm is used for this
signature, either `sha1` or `sha256`.
....
'gpgsig' SP <git-hash-algo> SP <signature-format> LF data
....

NOTE: This is highly experimental and the format of the data stream may
change in the future without compatibility guarantees.
The `gpgsig` command takes two arguments:

* `<git-hash-algo>` specifies which Git object format this signature
applies to, either `sha1` or `sha256`. This allows to know which
representation of the commit was signed (the SHA-1 or the SHA-256
version) which helps with both signature verification and
interoperability between repos with different hash functions.

* `<signature-format>` specifies the type of signature, such as
`openpgp`, `x509`, `ssh`, or `unknown`. This is a convenience for
tools that process the stream, so they don't have to parse the ASCII
armor to identify the signature type.

A commit may have at most one signature for the SHA-1 object format
(stored in the "gpgsig" header) and one for the SHA-256 object format
(stored in the "gpgsig-sha256" header).

See below for a detailed description of the `data` command which
contains the raw signature data.

Signatures are not yet checked in the current implementation
though. (Already setting the `extensions.compatObjectFormat`
configuration option might help with verifying both SHA-1 and SHA-256
object format signatures when it will be implemented.)

NOTE: This is highly experimental and the format of the `gpgsig`
command may change in the future without compatibility guarantees.

`encoding`
^^^^^^^^^^

View File

@ -29,6 +29,7 @@
#include "quote.h"
#include "remote.h"
#include "blob.h"
#include "gpg-interface.h"

static const char *const fast_export_usage[] = {
N_("git fast-export [<rev-list-opts>]"),
@ -652,6 +653,38 @@ static const char *find_commit_multiline_header(const char *msg,
return strbuf_detach(&val, NULL);
}

static void print_signature(const char *signature, const char *object_hash)
{
if (!signature)
return;

printf("gpgsig %s %s\ndata %u\n%s\n",
object_hash,
get_signature_format(signature),
(unsigned)strlen(signature),
signature);
}

static const char *append_signatures_for_header(struct string_list *signatures,
const char *pos,
const char *header,
const char *object_hash)
{
const char *signature;
const char *start = pos;
const char *end = pos;

while ((signature = find_commit_multiline_header(start + 1,
header,
&end))) {
string_list_append(signatures, signature)->util = (void *)object_hash;
free((char *)signature);
start = end;
}

return end;
}

static void handle_commit(struct commit *commit, struct rev_info *rev,
struct string_list *paths_of_changed_objects)
{
@ -660,7 +693,7 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
const char *author, *author_end, *committer, *committer_end;
const char *encoding = NULL;
size_t encoding_len;
const char *signature_alg = NULL, *signature = NULL;
struct string_list signatures = STRING_LIST_INIT_DUP;
const char *message;
char *reencoded = NULL;
struct commit_list *p;
@ -700,10 +733,11 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
}

if (*commit_buffer_cursor == '\n') {
if ((signature = find_commit_multiline_header(commit_buffer_cursor + 1, "gpgsig", &commit_buffer_cursor)))
signature_alg = "sha1";
else if ((signature = find_commit_multiline_header(commit_buffer_cursor + 1, "gpgsig-sha256", &commit_buffer_cursor)))
signature_alg = "sha256";
const char *after_sha1 = append_signatures_for_header(&signatures, commit_buffer_cursor,
"gpgsig", "sha1");
const char *after_sha256 = append_signatures_for_header(&signatures, commit_buffer_cursor,
"gpgsig-sha256", "sha256");
commit_buffer_cursor = (after_sha1 > after_sha256) ? after_sha1 : after_sha256;
}

message = strstr(commit_buffer_cursor, "\n\n");
@ -769,30 +803,30 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
printf("%.*s\n%.*s\n",
(int)(author_end - author), author,
(int)(committer_end - committer), committer);
if (signature) {
if (signatures.nr) {
switch (signed_commit_mode) {
case SIGN_ABORT:
die("encountered signed commit %s; use "
"--signed-commits=<mode> to handle it",
oid_to_hex(&commit->object.oid));
case SIGN_WARN_VERBATIM:
warning("exporting signed commit %s",
oid_to_hex(&commit->object.oid));
warning("exporting %"PRIuMAX" signature(s) for commit %s",
(uintmax_t)signatures.nr, oid_to_hex(&commit->object.oid));
/* fallthru */
case SIGN_VERBATIM:
printf("gpgsig %s\ndata %u\n%s",
signature_alg,
(unsigned)strlen(signature),
signature);
for (size_t i = 0; i < signatures.nr; i++) {
struct string_list_item *item = &signatures.items[i];
print_signature(item->string, item->util);
}
break;
case SIGN_WARN_STRIP:
warning("stripping signature from commit %s",
warning("stripping signature(s) from commit %s",
oid_to_hex(&commit->object.oid));
/* fallthru */
case SIGN_STRIP:
break;
}
free((char *)signature);
string_list_clear(&signatures, 0);
}
if (!reencoded && encoding)
printf("encoding %.*s\n", (int)encoding_len, encoding);

View File

@ -29,6 +29,7 @@
#include "commit-reach.h"
#include "khash.h"
#include "date.h"
#include "gpg-interface.h"

#define PACK_ID_BITS 16
#define MAX_PACK_ID ((1<<PACK_ID_BITS)-1)
@ -2716,15 +2717,82 @@ static struct hash_list *parse_merge(unsigned int *count)
return list;
}

struct signature_data {
char *hash_algo; /* "sha1" or "sha256" */
char *sig_format; /* "openpgp", "x509", "ssh", or "unknown" */
struct strbuf data; /* The actual signature data */
};

static void parse_one_signature(struct signature_data *sig, const char *v)
{
char *args = xstrdup(v); /* Will be freed when sig->hash_algo is freed */
char *space = strchr(args, ' ');

if (!space)
die("Expected gpgsig format: 'gpgsig <hash-algo> <signature-format>', "
"got 'gpgsig %s'", args);
*space = '\0';

sig->hash_algo = args;
sig->sig_format = space + 1;

/* Validate hash algorithm */
if (strcmp(sig->hash_algo, "sha1") &&
strcmp(sig->hash_algo, "sha256"))
die("Unknown git hash algorithm in gpgsig: '%s'", sig->hash_algo);

/* Validate signature format */
if (!valid_signature_format(sig->sig_format))
die("Invalid signature format in gpgsig: '%s'", sig->sig_format);
if (!strcmp(sig->sig_format, "unknown"))
warning("'unknown' signature format in gpgsig");

/* Read signature data */
read_next_command();
parse_data(&sig->data, 0, NULL);
}

static void add_gpgsig_to_commit(struct strbuf *commit_data,
const char *header,
struct signature_data *sig)
{
struct string_list siglines = STRING_LIST_INIT_NODUP;

if (!sig->hash_algo)
return;

strbuf_addstr(commit_data, header);
string_list_split_in_place(&siglines, sig->data.buf, "\n", -1);
strbuf_add_separated_string_list(commit_data, "\n ", &siglines);
strbuf_addch(commit_data, '\n');
string_list_clear(&siglines, 1);
strbuf_release(&sig->data);
free(sig->hash_algo);
}

static void store_signature(struct signature_data *stored_sig,
struct signature_data *new_sig,
const char *hash_type)
{
if (stored_sig->hash_algo) {
warning("multiple %s signatures found, "
"ignoring additional signature",
hash_type);
strbuf_release(&new_sig->data);
free(new_sig->hash_algo);
} else {
*stored_sig = *new_sig;
}
}

static void parse_new_commit(const char *arg)
{
static struct strbuf sig = STRBUF_INIT;
static struct strbuf msg = STRBUF_INIT;
struct string_list siglines = STRING_LIST_INIT_NODUP;
struct signature_data sig_sha1 = { NULL, NULL, STRBUF_INIT };
struct signature_data sig_sha256 = { NULL, NULL, STRBUF_INIT };
struct branch *b;
char *author = NULL;
char *committer = NULL;
char *sig_alg = NULL;
char *encoding = NULL;
struct hash_list *merge_list = NULL;
unsigned int merge_count;
@ -2748,13 +2816,23 @@ static void parse_new_commit(const char *arg)
}
if (!committer)
die("Expected committer but didn't get one");
if (skip_prefix(command_buf.buf, "gpgsig ", &v)) {
sig_alg = xstrdup(v);

/* Process signatures (up to 2: one "sha1" and one "sha256") */
while (skip_prefix(command_buf.buf, "gpgsig ", &v)) {
struct signature_data sig = { NULL, NULL, STRBUF_INIT };

parse_one_signature(&sig, v);

if (!strcmp(sig.hash_algo, "sha1"))
store_signature(&sig_sha1, &sig, "SHA-1");
else if (!strcmp(sig.hash_algo, "sha256"))
store_signature(&sig_sha256, &sig, "SHA-256");
else
BUG("parse_one_signature() returned unknown hash algo");

read_next_command();
parse_data(&sig, 0, NULL);
read_next_command();
} else
strbuf_setlen(&sig, 0);
}

if (skip_prefix(command_buf.buf, "encoding ", &v)) {
encoding = xstrdup(v);
read_next_command();
@ -2828,23 +2906,14 @@ static void parse_new_commit(const char *arg)
strbuf_addf(&new_data,
"encoding %s\n",
encoding);
if (sig_alg) {
if (!strcmp(sig_alg, "sha1"))
strbuf_addstr(&new_data, "gpgsig ");
else if (!strcmp(sig_alg, "sha256"))
strbuf_addstr(&new_data, "gpgsig-sha256 ");
else
die("Expected gpgsig algorithm sha1 or sha256, got %s", sig_alg);
string_list_split_in_place(&siglines, sig.buf, "\n", -1);
strbuf_add_separated_string_list(&new_data, "\n ", &siglines);
strbuf_addch(&new_data, '\n');
}

add_gpgsig_to_commit(&new_data, "gpgsig ", &sig_sha1);
add_gpgsig_to_commit(&new_data, "gpgsig-sha256 ", &sig_sha256);

strbuf_addch(&new_data, '\n');
strbuf_addbuf(&new_data, &msg);
string_list_clear(&siglines, 1);
free(author);
free(committer);
free(sig_alg);
free(encoding);

if (!store_object(OBJ_COMMIT, &new_data, NULL, &b->oid, next_mark))

View File

@ -144,6 +144,18 @@ static struct gpg_format *get_format_by_sig(const char *sig)
return NULL;
}

const char *get_signature_format(const char *buf)
{
struct gpg_format *format = get_format_by_sig(buf);
return format ? format->name : "unknown";
}

int valid_signature_format(const char *format)
{
return (!!get_format_by_name(format) ||
!strcmp(format, "unknown"));
}

void signature_check_clear(struct signature_check *sigc)
{
FREE_AND_NULL(sigc->payload);

View File

@ -47,6 +47,18 @@ struct signature_check {

void signature_check_clear(struct signature_check *sigc);

/*
* Return the format of the signature (like "openpgp", "x509", "ssh"
* or "unknown").
*/
const char *get_signature_format(const char *buf);

/*
* Is the signature format valid (like "openpgp", "x509", "ssh" or
* "unknown")
*/
int valid_signature_format(const char *format);

/*
* Look at a GPG signed tag object. If such a signature exists, store it in
* signature and the signed content in payload. Return 1 if a signature was

View File

@ -314,7 +314,7 @@ test_expect_success GPG 'signed-commits=abort' '
test_expect_success GPG 'signed-commits=verbatim' '

git fast-export --signed-commits=verbatim --reencode=no commit-signing >output &&
grep "^gpgsig sha" output &&
test_grep -E "^gpgsig $GIT_DEFAULT_HASH openpgp" output &&
grep "encoding ISO-8859-1" output &&
(
cd new &&
@ -328,7 +328,7 @@ test_expect_success GPG 'signed-commits=verbatim' '
test_expect_success GPG 'signed-commits=warn-verbatim' '

git fast-export --signed-commits=warn-verbatim --reencode=no commit-signing >output 2>err &&
grep "^gpgsig sha" output &&
test_grep -E "^gpgsig $GIT_DEFAULT_HASH openpgp" output &&
grep "encoding ISO-8859-1" output &&
test -s err &&
(
@ -369,6 +369,62 @@ test_expect_success GPG 'signed-commits=warn-strip' '

'

test_expect_success GPGSM 'setup X.509 signed commit' '

git checkout -b x509-signing main &&
test_config gpg.format x509 &&
test_config user.signingkey $GIT_COMMITTER_EMAIL &&
echo "X.509 content" >file &&
git add file &&
git commit -S -m "X.509 signed commit" &&
X509_COMMIT=$(git rev-parse HEAD) &&
git checkout main

'

test_expect_success GPGSM 'round-trip X.509 signed commit' '

git fast-export --signed-commits=verbatim x509-signing >output &&
test_grep -E "^gpgsig $GIT_DEFAULT_HASH x509" output &&
(
cd new &&
git fast-import &&
git cat-file commit refs/heads/x509-signing >actual &&
grep "^gpgsig" actual &&
IMPORTED=$(git rev-parse refs/heads/x509-signing) &&
test $X509_COMMIT = $IMPORTED
) <output

'

test_expect_success GPGSSH 'setup SSH signed commit' '

git checkout -b ssh-signing main &&
test_config gpg.format ssh &&
test_config user.signingkey "${GPGSSH_KEY_PRIMARY}" &&
echo "SSH content" >file &&
git add file &&
git commit -S -m "SSH signed commit" &&
SSH_COMMIT=$(git rev-parse HEAD) &&
git checkout main

'

test_expect_success GPGSSH 'round-trip SSH signed commit' '

git fast-export --signed-commits=verbatim ssh-signing >output &&
test_grep -E "^gpgsig $GIT_DEFAULT_HASH ssh" output &&
(
cd new &&
git fast-import &&
git cat-file commit refs/heads/ssh-signing >actual &&
grep "^gpgsig" actual &&
IMPORTED=$(git rev-parse refs/heads/ssh-signing) &&
test $SSH_COMMIT = $IMPORTED
) <output

'

test_expect_success 'setup submodule' '

test_config_global protocol.file.allow always &&
@ -905,4 +961,46 @@ test_expect_success 'fast-export handles --end-of-options' '
test_cmp expect actual
'

test_expect_success GPG 'setup a commit with dual signatures on its SHA-1 and SHA-256 formats' '
# Create a signed SHA-256 commit
git init --object-format=sha256 explicit-sha256 &&
git -C explicit-sha256 config extensions.compatObjectFormat sha1 &&
git -C explicit-sha256 checkout -b dual-signed &&
test_commit -C explicit-sha256 A &&
echo B >explicit-sha256/B &&
git -C explicit-sha256 add B &&
test_tick &&
git -C explicit-sha256 commit -S -m "signed" B &&
SHA256_B=$(git -C explicit-sha256 rev-parse dual-signed) &&

# Create the corresponding SHA-1 commit
SHA1_B=$(git -C explicit-sha256 rev-parse --output-object-format=sha1 dual-signed) &&

# Check that the resulting SHA-1 commit has both signatures
echo $SHA1_B | git -C explicit-sha256 cat-file --batch >out &&
test_grep -E "^gpgsig " out &&
test_grep -E "^gpgsig-sha256 " out
'

test_expect_success GPG 'export and import of doubly signed commit' '
git -C explicit-sha256 fast-export --signed-commits=verbatim dual-signed >output &&
test_grep -E "^gpgsig sha1 openpgp" output &&
test_grep -E "^gpgsig sha256 openpgp" output &&

(
cd new &&
git fast-import &&
git cat-file commit refs/heads/dual-signed >actual &&
test_grep -E "^gpgsig " actual &&
test_grep -E "^gpgsig-sha256 " actual &&
IMPORTED=$(git rev-parse refs/heads/dual-signed) &&
if test "$GIT_DEFAULT_HASH" = "sha1"
then
test $SHA1_B = $IMPORTED
else
test $SHA256_B = $IMPORTED
fi
) <output
'

test_done