fast-export, fast-import: add support for signed-commits

fast-export has a --signed-tags= option that controls how to handle tag
signatures.  However, there is no equivalent for commit signatures; it
just silently strips the signature out of the commit (analogously to
--signed-tags=strip).

While signatures are generally problematic for fast-export/fast-import
(because hashes are likely to change), if they're going to support tag
signatures, there's no reason to not also support commit signatures.

So, implement a --signed-commits= option that mirrors the --signed-tags=
option.

On the fast-export side, try to be as much like signed-tags as possible,
in both implementation and in user-interface.  This will change the
default behavior to '--signed-commits=abort' from what is now
'--signed-commits=strip'.  In order to provide an escape hatch for users
of third-party tools that call fast-export and do not yet know of the
--signed-commits= option, add an environment variable
'FAST_EXPORT_SIGNED_COMMITS_NOABORT=1' that changes the default to
'--signed-commits=warn-strip'.

Signed-off-by: Luke Shumaker <lukeshu@datawire.io>
Signed-off-by: Christian Couder <chriscool@tuxfamily.org>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
maint
Luke Shumaker 2025-03-10 16:57:46 +01:00 committed by Junio C Hamano
parent dda9bff3c5
commit d9cb0e6ff8
5 changed files with 253 additions and 20 deletions

View File

@ -44,6 +44,17 @@ affecting tags or any commit in their history will be performed by you
or by fast-export or fast-import, or if you do not care that the or by fast-export or fast-import, or if you do not care that the
resulting tag will have an invalid signature. resulting tag will have an invalid signature.


--signed-commits=(verbatim|warn-verbatim|warn-strip|strip|abort)::
Specify how to handle signed commits. Behaves exactly as
'--signed-tags', but for commits. Default is 'abort'.
+
Earlier versions this command that did not have '--signed-commits'
behaved as if '--signed-commits=strip'. As an escape hatch for users
of tools that call 'git fast-export' but do not yet support
'--signed-commits', you may set the environment variable
'FAST_EXPORT_SIGNED_COMMITS_NOABORT=1' in order to change the default
from 'abort' to 'warn-strip'.

--tag-of-filtered-object=(abort|drop|rewrite):: --tag-of-filtered-object=(abort|drop|rewrite)::
Specify how to handle tags whose tagged object is filtered out. Specify how to handle tags whose tagged object is filtered out.
Since revisions and files to export can be limited by path, Since revisions and files to export can be limited by path,

View File

@ -431,12 +431,21 @@ and control the current import process. More detailed discussion
Create or update a branch with a new commit, recording one logical Create or update a branch with a new commit, recording one logical
change to the project. change to the project.


////
Yes, it's intentional that the 'gpgsig' line doesn't have a trailing
`LF`; the definition of `data` has a byte-count prefix, so it
doesn't need an `LF` to act as a terminator (and `data` also already
includes an optional trailing `LF?` just in case you want to include
one).
////

.... ....
'commit' SP <ref> LF 'commit' SP <ref> LF
mark? mark?
original-oid? original-oid?
('author' (SP <name>)? SP LT <email> GT SP <when> LF)? ('author' (SP <name>)? SP LT <email> GT SP <when> LF)?
'committer' (SP <name>)? SP LT <email> GT SP <when> LF 'committer' (SP <name>)? SP LT <email> GT SP <when> LF
('gpgsig' SP <alg> LF data)?
('encoding' SP <encoding> LF)? ('encoding' SP <encoding> LF)?
data data
('from' SP <commit-ish> LF)? ('from' SP <commit-ish> LF)?
@ -505,6 +514,15 @@ that was selected by the --date-format=<fmt> command-line option.
See ``Date Formats'' above for the set of supported formats, and See ``Date Formats'' above for the set of supported formats, and
their syntax. their syntax.


`gpgsig`
^^^^^^^^

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

Here <alg> specifies which hashing algorithm is used for this
signature, either `sha1` or `sha256`.

`encoding` `encoding`
^^^^^^^^^^ ^^^^^^^^^^
The optional `encoding` command indicates the encoding of the commit The optional `encoding` command indicates the encoding of the commit

View File

@ -35,8 +35,11 @@ static const char *fast_export_usage[] = {
NULL NULL
}; };


enum sign_mode { SIGN_ABORT, SIGN_VERBATIM, SIGN_STRIP, SIGN_WARN_VERBATIM, SIGN_WARN_STRIP };

static int progress; static int progress;
static enum signed_tag_mode { SIGNED_TAG_ABORT, VERBATIM, WARN_VERBATIM, WARN_STRIP, STRIP } signed_tag_mode = SIGNED_TAG_ABORT; static enum sign_mode signed_tag_mode = SIGN_ABORT;
static enum sign_mode signed_commit_mode = SIGN_ABORT;
static enum tag_of_filtered_mode { TAG_FILTERING_ABORT, DROP, REWRITE } tag_of_filtered_mode = TAG_FILTERING_ABORT; static enum tag_of_filtered_mode { TAG_FILTERING_ABORT, DROP, REWRITE } tag_of_filtered_mode = TAG_FILTERING_ABORT;
static enum reencode_mode { REENCODE_ABORT, REENCODE_YES, REENCODE_NO } reencode_mode = REENCODE_ABORT; static enum reencode_mode { REENCODE_ABORT, REENCODE_YES, REENCODE_NO } reencode_mode = REENCODE_ABORT;
static int fake_missing_tagger; static int fake_missing_tagger;
@ -53,23 +56,24 @@ static int anonymize;
static struct hashmap anonymized_seeds; static struct hashmap anonymized_seeds;
static struct revision_sources revision_sources; static struct revision_sources revision_sources;


static int parse_opt_signed_tag_mode(const struct option *opt, static int parse_opt_sign_mode(const struct option *opt,
const char *arg, int unset) const char *arg, int unset)
{ {
enum signed_tag_mode *val = opt->value; enum sign_mode *val = opt->value;

if (unset)
if (unset || !strcmp(arg, "abort")) return 0;
*val = SIGNED_TAG_ABORT; else if (!strcmp(arg, "abort"))
*val = SIGN_ABORT;
else if (!strcmp(arg, "verbatim") || !strcmp(arg, "ignore")) else if (!strcmp(arg, "verbatim") || !strcmp(arg, "ignore"))
*val = VERBATIM; *val = SIGN_VERBATIM;
else if (!strcmp(arg, "warn-verbatim") || !strcmp(arg, "warn")) else if (!strcmp(arg, "warn-verbatim") || !strcmp(arg, "warn"))
*val = WARN_VERBATIM; *val = SIGN_WARN_VERBATIM;
else if (!strcmp(arg, "warn-strip")) else if (!strcmp(arg, "warn-strip"))
*val = WARN_STRIP; *val = SIGN_WARN_STRIP;
else if (!strcmp(arg, "strip")) else if (!strcmp(arg, "strip"))
*val = STRIP; *val = SIGN_STRIP;
else else
return error("Unknown signed-tags mode: %s", arg); return error("Unknown %s mode: %s", opt->long_name, arg);
return 0; return 0;
} }


@ -611,6 +615,43 @@ static void anonymize_ident_line(const char **beg, const char **end)
*end = out->buf + out->len; *end = out->buf + out->len;
} }


/*
* find_commit_multiline_header is similar to find_commit_header,
* except that it handles multi-line headers, rather than simply
* returning the first line of the header.
*
* The returned string has had the ' ' line continuation markers
* removed, and points to allocated memory that must be free()d (not
* to memory within 'msg').
*
* If the header is found, then *end is set to point at the '\n' in
* msg that immediately follows the header value.
*/
static const char *find_commit_multiline_header(const char *msg,
const char *key,
const char **end)
{
struct strbuf val = STRBUF_INIT;
const char *bol, *eol;
size_t len;

bol = find_commit_header(msg, key, &len);
if (!bol)
return NULL;
eol = bol + len;
strbuf_add(&val, bol, len);

while (eol[0] == '\n' && eol[1] == ' ') {
bol = eol + 2;
eol = strchrnul(bol, '\n');
strbuf_addch(&val, '\n');
strbuf_add(&val, bol, eol - bol);
}

*end = eol;
return strbuf_detach(&val, NULL);
}

static void handle_commit(struct commit *commit, struct rev_info *rev, static void handle_commit(struct commit *commit, struct rev_info *rev,
struct string_list *paths_of_changed_objects) struct string_list *paths_of_changed_objects)
{ {
@ -619,6 +660,7 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
const char *author, *author_end, *committer, *committer_end; const char *author, *author_end, *committer, *committer_end;
const char *encoding = NULL; const char *encoding = NULL;
size_t encoding_len; size_t encoding_len;
const char *signature_alg = NULL, *signature = NULL;
const char *message; const char *message;
char *reencoded = NULL; char *reencoded = NULL;
struct commit_list *p; struct commit_list *p;
@ -645,17 +687,25 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
commit_buffer_cursor = committer_end = strchrnul(committer, '\n'); commit_buffer_cursor = committer_end = strchrnul(committer, '\n');


/* /*
* find_commit_header() gets a `+ 1` because * find_commit_header() and find_commit_multiline_header() get
* commit_buffer_cursor points at the trailing "\n" at the end * a `+ 1` because commit_buffer_cursor points at the trailing
* of the previous line, but find_commit_header() wants a * "\n" at the end of the previous line, but they want a
* pointer to the beginning of the next line. * pointer to the beginning of the next line.
*/ */

if (*commit_buffer_cursor == '\n') { if (*commit_buffer_cursor == '\n') {
encoding = find_commit_header(commit_buffer_cursor + 1, "encoding", &encoding_len); encoding = find_commit_header(commit_buffer_cursor + 1, "encoding", &encoding_len);
if (encoding) if (encoding)
commit_buffer_cursor = encoding + encoding_len; commit_buffer_cursor = encoding + encoding_len;
} }


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";
}

message = strstr(commit_buffer_cursor, "\n\n"); message = strstr(commit_buffer_cursor, "\n\n");
if (message) if (message)
message += 2; message += 2;
@ -719,6 +769,31 @@ static void handle_commit(struct commit *commit, struct rev_info *rev,
printf("%.*s\n%.*s\n", printf("%.*s\n%.*s\n",
(int)(author_end - author), author, (int)(author_end - author), author,
(int)(committer_end - committer), committer); (int)(committer_end - committer), committer);
if (signature) {
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));
/* fallthru */
case SIGN_VERBATIM:
printf("gpgsig %s\ndata %u\n%s",
signature_alg,
(unsigned)strlen(signature),
signature);
break;
case SIGN_WARN_STRIP:
warning("stripping signature from commit %s",
oid_to_hex(&commit->object.oid));
/* fallthru */
case SIGN_STRIP:
break;
}
free((char *)signature);
}
if (!reencoded && encoding) if (!reencoded && encoding)
printf("encoding %.*s\n", (int)encoding_len, encoding); printf("encoding %.*s\n", (int)encoding_len, encoding);
printf("data %u\n%s", printf("data %u\n%s",
@ -834,21 +909,21 @@ static void handle_tag(const char *name, struct tag *tag)
"\n-----BEGIN PGP SIGNATURE-----\n"); "\n-----BEGIN PGP SIGNATURE-----\n");
if (signature) if (signature)
switch (signed_tag_mode) { switch (signed_tag_mode) {
case SIGNED_TAG_ABORT: case SIGN_ABORT:
die("encountered signed tag %s; use " die("encountered signed tag %s; use "
"--signed-tags=<mode> to handle it", "--signed-tags=<mode> to handle it",
oid_to_hex(&tag->object.oid)); oid_to_hex(&tag->object.oid));
case WARN_VERBATIM: case SIGN_WARN_VERBATIM:
warning("exporting signed tag %s", warning("exporting signed tag %s",
oid_to_hex(&tag->object.oid)); oid_to_hex(&tag->object.oid));
/* fallthru */ /* fallthru */
case VERBATIM: case SIGN_VERBATIM:
break; break;
case WARN_STRIP: case SIGN_WARN_STRIP:
warning("stripping signature from tag %s", warning("stripping signature from tag %s",
oid_to_hex(&tag->object.oid)); oid_to_hex(&tag->object.oid));
/* fallthru */ /* fallthru */
case STRIP: case SIGN_STRIP:
message_size = signature + 1 - message; message_size = signature + 1 - message;
break; break;
} }
@ -1194,6 +1269,7 @@ int cmd_fast_export(int argc,
const char *prefix, const char *prefix,
struct repository *repo UNUSED) struct repository *repo UNUSED)
{ {
const char *env_signed_commits_noabort;
struct rev_info revs; struct rev_info revs;
struct commit *commit; struct commit *commit;
char *export_filename = NULL, char *export_filename = NULL,
@ -1207,7 +1283,10 @@ int cmd_fast_export(int argc,
N_("show progress after <n> objects")), N_("show progress after <n> objects")),
OPT_CALLBACK(0, "signed-tags", &signed_tag_mode, N_("mode"), OPT_CALLBACK(0, "signed-tags", &signed_tag_mode, N_("mode"),
N_("select handling of signed tags"), N_("select handling of signed tags"),
parse_opt_signed_tag_mode), parse_opt_sign_mode),
OPT_CALLBACK(0, "signed-commits", &signed_commit_mode, N_("mode"),
N_("select handling of signed commits"),
parse_opt_sign_mode),
OPT_CALLBACK(0, "tag-of-filtered-object", &tag_of_filtered_mode, N_("mode"), OPT_CALLBACK(0, "tag-of-filtered-object", &tag_of_filtered_mode, N_("mode"),
N_("select handling of tags that tag filtered objects"), N_("select handling of tags that tag filtered objects"),
parse_opt_tag_of_filtered_mode), parse_opt_tag_of_filtered_mode),
@ -1248,6 +1327,10 @@ int cmd_fast_export(int argc,
if (argc == 1) if (argc == 1)
usage_with_options (fast_export_usage, options); usage_with_options (fast_export_usage, options);


env_signed_commits_noabort = getenv("FAST_EXPORT_SIGNED_COMMITS_NOABORT");
if (env_signed_commits_noabort && *env_signed_commits_noabort)
signed_commit_mode = SIGN_WARN_STRIP;

/* we handle encodings */ /* we handle encodings */
git_config(git_default_config, NULL); git_config(git_default_config, NULL);



View File

@ -2719,10 +2719,13 @@ static struct hash_list *parse_merge(unsigned int *count)


static void parse_new_commit(const char *arg) static void parse_new_commit(const char *arg)
{ {
static struct strbuf sig = STRBUF_INIT;
static struct strbuf msg = STRBUF_INIT; static struct strbuf msg = STRBUF_INIT;
struct string_list siglines = STRING_LIST_INIT_NODUP;
struct branch *b; struct branch *b;
char *author = NULL; char *author = NULL;
char *committer = NULL; char *committer = NULL;
char *sig_alg = NULL;
char *encoding = NULL; char *encoding = NULL;
struct hash_list *merge_list = NULL; struct hash_list *merge_list = NULL;
unsigned int merge_count; unsigned int merge_count;
@ -2746,6 +2749,13 @@ static void parse_new_commit(const char *arg)
} }
if (!committer) if (!committer)
die("Expected committer but didn't get one"); die("Expected committer but didn't get one");
if (skip_prefix(command_buf.buf, "gpgsig ", &v)) {
sig_alg = xstrdup(v);
read_next_command();
parse_data(&sig, 0, NULL);
read_next_command();
} else
strbuf_setlen(&sig, 0);
if (skip_prefix(command_buf.buf, "encoding ", &v)) { if (skip_prefix(command_buf.buf, "encoding ", &v)) {
encoding = xstrdup(v); encoding = xstrdup(v);
read_next_command(); read_next_command();
@ -2819,10 +2829,23 @@ static void parse_new_commit(const char *arg)
strbuf_addf(&new_data, strbuf_addf(&new_data,
"encoding %s\n", "encoding %s\n",
encoding); 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');
}
strbuf_addch(&new_data, '\n'); strbuf_addch(&new_data, '\n');
strbuf_addbuf(&new_data, &msg); strbuf_addbuf(&new_data, &msg);
string_list_clear(&siglines, 1);
free(author); free(author);
free(committer); free(committer);
free(sig_alg);
free(encoding); free(encoding);


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

View File

@ -8,6 +8,7 @@ GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=main
export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME


. ./test-lib.sh . ./test-lib.sh
. "$TEST_DIRECTORY/lib-gpg.sh"


test_expect_success 'setup' ' test_expect_success 'setup' '


@ -284,10 +285,107 @@ test_expect_success 'signed-tags=warn-strip' '
test -s err test -s err
' '


test_expect_success GPG 'set up signed commit' '

# Generate a commit with both "gpgsig" and "encoding" set, so
# that we can test that fast-import gets the ordering correct
# between the two.
test_config i18n.commitEncoding ISO-8859-1 &&
git checkout -f -b commit-signing main &&
echo Sign your name >file-sign &&
git add file-sign &&
git commit -S -m "signed commit" &&
COMMIT_SIGNING=$(git rev-parse --verify commit-signing)

'

test_expect_success GPG 'signed-commits default' '

sane_unset FAST_EXPORT_SIGNED_COMMITS_NOABORT &&
test_must_fail git fast-export --reencode=no commit-signing &&

FAST_EXPORT_SIGNED_COMMITS_NOABORT=1 git fast-export --reencode=no commit-signing >output 2>err &&
! grep ^gpgsig output &&
grep "^encoding ISO-8859-1" output &&
test -s err &&
sed "s/commit-signing/commit-strip-signing/" output | (
cd new &&
git fast-import &&
STRIPPED=$(git rev-parse --verify refs/heads/commit-strip-signing) &&
test $COMMIT_SIGNING != $STRIPPED
)

'

test_expect_success GPG 'signed-commits=abort' '

test_must_fail git fast-export --signed-commits=abort commit-signing

'

test_expect_success GPG 'signed-commits=verbatim' '

git fast-export --signed-commits=verbatim --reencode=no commit-signing >output &&
grep "^gpgsig sha" output &&
grep "encoding ISO-8859-1" output &&
(
cd new &&
git fast-import &&
STRIPPED=$(git rev-parse --verify refs/heads/commit-signing) &&
test $COMMIT_SIGNING = $STRIPPED
) <output

'

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 &&
grep "encoding ISO-8859-1" output &&
test -s err &&
(
cd new &&
git fast-import &&
STRIPPED=$(git rev-parse --verify refs/heads/commit-signing) &&
test $COMMIT_SIGNING = $STRIPPED
) <output

'

test_expect_success GPG 'signed-commits=strip' '

git fast-export --signed-commits=strip --reencode=no commit-signing >output &&
! grep ^gpgsig output &&
grep "^encoding ISO-8859-1" output &&
sed "s/commit-signing/commit-strip-signing/" output | (
cd new &&
git fast-import &&
STRIPPED=$(git rev-parse --verify refs/heads/commit-strip-signing) &&
test $COMMIT_SIGNING != $STRIPPED
)

'

test_expect_success GPG 'signed-commits=warn-strip' '

git fast-export --signed-commits=warn-strip --reencode=no commit-signing >output 2>err &&
! grep ^gpgsig output &&
grep "^encoding ISO-8859-1" output &&
test -s err &&
sed "s/commit-signing/commit-strip-signing/" output | (
cd new &&
git fast-import &&
STRIPPED=$(git rev-parse --verify refs/heads/commit-strip-signing) &&
test $COMMIT_SIGNING != $STRIPPED
)

'

test_expect_success 'setup submodule' ' test_expect_success 'setup submodule' '


test_config_global protocol.file.allow always && test_config_global protocol.file.allow always &&
git checkout -f main && git checkout -f main &&
test_might_fail git update-ref -d refs/heads/commit-signing &&
mkdir sub && mkdir sub &&
( (
cd sub && cd sub &&