Skip to content
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

feat: add merge support for git-crypt and update documentation #1

Merged
merged 4 commits into from
Nov 12, 2024
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
9 changes: 4 additions & 5 deletions README
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ Configure a repository to use git-crypt:

Specify files to encrypt by creating a .gitattributes file:

secretfile filter=git-crypt diff=git-crypt
*.key filter=git-crypt diff=git-crypt
secretdir/** filter=git-crypt diff=git-crypt
secretfile filter=git-crypt diff=git-crypt merge=git-crypt
*.key filter=git-crypt diff=git-crypt merge=git-crypt

Like a .gitignore file, it can match wildcards and should be checked into
the repository. See below for more information about .gitattributes.
Expand Down Expand Up @@ -151,10 +150,10 @@ encrypt all files beneath it.
Also note that the pattern `dir/*` does not match files under
sub-directories of dir/. To encrypt an entire sub-tree dir/, use `dir/**`:

dir/** filter=git-crypt diff=git-crypt
dir/** filter=git-crypt diff=git-crypt merge=git-crypt

The .gitattributes file must not be encrypted, so make sure wildcards don't
match it accidentally. If necessary, you can exclude .gitattributes from
encryption like this:

.gitattributes !filter !diff
.gitattributes !filter !diff !merge
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ Configure a repository to use git-crypt:

Specify files to encrypt by creating a .gitattributes file:

secretfile eol=lf filter=git-crypt diff=git-crypt
*.key eol=lf filter=git-crypt diff=git-crypt
secretdir/** eol=lf filter=git-crypt diff=git-crypt
secretfile eol=lf filter=git-crypt diff=git-crypt merge=git-crypt
*.key eol=lf filter=git-crypt diff=git-crypt merge=git-crypt
secretdir/** eol=lf filter=git-crypt diff=git-crypt merge=git-crypt

***eol=lf settings is necessary if autocrlf is true.***

Expand Down Expand Up @@ -158,10 +158,10 @@ encrypt all files beneath it.
Also note that the pattern `dir/*` does not match files under
sub-directories of dir/. To encrypt an entire sub-tree dir/, use `dir/**`:

dir/** filter=git-crypt diff=git-crypt
dir/** filter=git-crypt diff=git-crypt merge=git-crypt

The .gitattributes file must not be encrypted, so make sure wildcards don't
match it accidentally. If necessary, you can exclude .gitattributes from
encryption like this:

.gitattributes !filter !diff
.gitattributes !filter !diff !merge
149 changes: 130 additions & 19 deletions commands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,16 @@ static void configure_git_filters (const char* key_name)
git_config(std::string("filter.git-crypt-") + key_name + ".required", "true");
git_config(std::string("diff.git-crypt-") + key_name + ".textconv",
escaped_git_crypt_path + " diff --key-name=" + key_name);
git_config(std::string("merge.git-crypt-") + key_name + ".name", "git-crypt merge driver");
git_config(std::string("merge.git-crypt-") + key_name + ".driver",
escaped_git_crypt_path + " merge --key-name=" + key_name + " %A %O %B %L");
} else {
git_config("filter.git-crypt.smudge", escaped_git_crypt_path + " smudge");
git_config("filter.git-crypt.clean", escaped_git_crypt_path + " clean");
git_config("filter.git-crypt.required", "true");
git_config("diff.git-crypt.textconv", escaped_git_crypt_path + " diff");
git_config("merge.git-crypt.name", "git-crypt merge driver");
git_config("merge.git-crypt.driver", escaped_git_crypt_path + " merge %A %O %B %L");
}
}

Expand All @@ -190,6 +195,12 @@ static void deconfigure_git_filters (const char* key_name)
if (git_has_config("diff." + attribute_name(key_name) + ".textconv")) {
git_deconfig("diff." + attribute_name(key_name));
}

if (git_has_config("merge." + attribute_name(key_name) + ".name") ||
git_has_config("merge." + attribute_name(key_name) + ".driver")) {

git_deconfig("merge." + attribute_name(key_name));
}
}

static bool git_checkout_batch (std::vector<std::string>::const_iterator paths_begin, std::vector<std::string>::const_iterator paths_end)
Expand Down Expand Up @@ -717,8 +728,8 @@ static int parse_plumbing_options (const char** key_name, const char** key_file,
return parse_options(options, argc, argv);
}

// Encrypt contents of stdin and write to stdout
int clean (int argc, const char** argv)
// Encrypt contents of &in and write to &out
int clean (int argc, const char** argv, std::istream& in, std::ostream& out)
{
const char* key_name = 0;
const char* key_path = 0;
Expand Down Expand Up @@ -751,10 +762,10 @@ int clean (int argc, const char** argv)

char buffer[1024];

while (std::cin && file_size < Aes_ctr_encryptor::MAX_CRYPT_BYTES) {
std::cin.read(buffer, sizeof(buffer));
while (in && file_size < Aes_ctr_encryptor::MAX_CRYPT_BYTES) {
in.read(buffer, sizeof(buffer));

const size_t bytes_read = std::cin.gcount();
const size_t bytes_read = in.gcount();

hmac.add(reinterpret_cast<unsigned char*>(buffer), bytes_read);
file_size += bytes_read;
Expand Down Expand Up @@ -802,8 +813,8 @@ int clean (int argc, const char** argv)
hmac.get(digest);

// Write a header that...
std::cout.write("\0GITCRYPT\0", 10); // ...identifies this as an encrypted file
std::cout.write(reinterpret_cast<char*>(digest), Aes_ctr_encryptor::NONCE_LEN); // ...includes the nonce
out.write("\0GITCRYPT\0", 10); // ...identifies this as an encrypted file
out.write(reinterpret_cast<char*>(digest), Aes_ctr_encryptor::NONCE_LEN); // ...includes the nonce

// Now encrypt the file and write to stdout
Aes_ctr_encryptor aes(key->aes_key, digest);
Expand All @@ -814,7 +825,7 @@ int clean (int argc, const char** argv)
while (file_data_len > 0) {
const size_t buffer_len = std::min(sizeof(buffer), file_data_len);
aes.process(file_data, reinterpret_cast<unsigned char*>(buffer), buffer_len);
std::cout.write(buffer, buffer_len);
out.write(buffer, buffer_len);
file_data += buffer_len;
file_data_len -= buffer_len;
}
Expand All @@ -830,14 +841,14 @@ int clean (int argc, const char** argv)
aes.process(reinterpret_cast<unsigned char*>(buffer),
reinterpret_cast<unsigned char*>(buffer),
buffer_len);
std::cout.write(buffer, buffer_len);
out.write(buffer, buffer_len);
}
}

return 0;
}

static int decrypt_file_to_stdout (const Key_file& key_file, const unsigned char* header, std::istream& in)
static int decrypt_file_to_stream (const Key_file& key_file, const unsigned char* header, std::istream& in, std::ostream& out = std::cout)
{
const unsigned char* nonce = header + 10;
uint32_t key_version = 0; // TODO: get the version from the file header
Expand All @@ -855,7 +866,7 @@ static int decrypt_file_to_stdout (const Key_file& key_file, const unsigned char
in.read(reinterpret_cast<char*>(buffer), sizeof(buffer));
aes.process(buffer, buffer, in.gcount());
hmac.add(buffer, in.gcount());
std::cout.write(reinterpret_cast<char*>(buffer), in.gcount());
out.write(reinterpret_cast<char*>(buffer), in.gcount());
}

unsigned char digest[Hmac_sha1_state::LEN];
Expand All @@ -871,8 +882,8 @@ static int decrypt_file_to_stdout (const Key_file& key_file, const unsigned char
return 0;
}

// Decrypt contents of stdin and write to stdout
int smudge (int argc, const char** argv)
// Decrypt contents of &in and write to &out
int smudge (int argc, const char** argv, std::istream& in, std::ostream& out)
{
const char* key_name = 0;
const char* key_path = 0;
Expand All @@ -891,21 +902,21 @@ int smudge (int argc, const char** argv)

// Read the header to get the nonce and make sure it's actually encrypted
unsigned char header[10 + Aes_ctr_decryptor::NONCE_LEN];
std::cin.read(reinterpret_cast<char*>(header), sizeof(header));
if (std::cin.gcount() != sizeof(header) || std::memcmp(header, "\0GITCRYPT\0", 10) != 0) {
in.read(reinterpret_cast<char*>(header), sizeof(header));
if (in.gcount() != sizeof(header) || std::memcmp(header, "\0GITCRYPT\0", 10) != 0) {
// File not encrypted - just copy it out to stdout
std::clog << "git-crypt: Warning: file not encrypted" << std::endl;
std::clog << "git-crypt: Run 'git-crypt status' to make sure all files are properly encrypted." << std::endl;
std::clog << "git-crypt: If 'git-crypt status' reports no problems, then an older version of" << std::endl;
std::clog << "git-crypt: this file may be unencrypted in the repository's history. If this" << std::endl;
std::clog << "git-crypt: file contains sensitive information, you can use 'git filter-branch'" << std::endl;
std::clog << "git-crypt: to remove its old versions from the history." << std::endl;
std::cout.write(reinterpret_cast<char*>(header), std::cin.gcount()); // include the bytes which we already read
std::cout << std::cin.rdbuf();
out.write(reinterpret_cast<char*>(header), in.gcount()); // include the bytes which we already read
out << in.rdbuf();
return 0;
}

return decrypt_file_to_stdout(key_file, header, std::cin);
return decrypt_file_to_stream(key_file, header, in, out);
}

int diff (int argc, const char** argv)
Expand Down Expand Up @@ -947,7 +958,107 @@ int diff (int argc, const char** argv)
}

// Go ahead and decrypt it
return decrypt_file_to_stdout(key_file, header, in);
return decrypt_file_to_stream(key_file, header, in);
}

int merge (int argc, const char** argv)
{
const char* key_name = 0; // unused but needed
const char* key_path = 0; // unused but needed
const char* current_path = 0; // %A
const char* base_path = 0; // %O
const char* other_path = 0; // %B
const char* marker_size = 0; // %L

int argi = parse_plumbing_options(&key_name, &key_path, argc, argv);
if (argc - argi == 4) {
current_path = argv[argi];
base_path = argv[argi + 1];
other_path = argv[argi + 2];
marker_size = argv[argi + 3];
} else {
std::clog << "Usage: git-crypt merge [--key-name=NAME] [--key-file=PATH] CURRENT BASE OTHER MARKER_SIZE" << std::endl;
return 2;
}

// Run smudge on input files
std::vector<std::string> smudge_files;
smudge_files.push_back(current_path);
smudge_files.push_back(base_path);
smudge_files.push_back(other_path);

for (std::vector<std::string>::const_iterator file(smudge_files.begin()); file != smudge_files.end(); ++file) {
std::ifstream in(*file, std::ifstream::binary);
if (!in) {
std::clog << "git-crypt: " << *file << ": unable to open for reading" << std::endl;
return 1;
}
in.exceptions(std::ifstream::badbit);

std::ofstream out(*file + ".tmp", std::ofstream::binary | std::ofstream::trunc);
if (!out) {
std::clog << "git-crypt: " << *file << ".tmp: unable to open for writing" << std::endl;
return 1;
}
out.exceptions(std::ifstream::badbit);

if (smudge(argi, argv, in, out) != 0) {
std::clog << "Error: failed to smudge " << *file << ": unable to merge file" << std::endl;
return 1;
}
in.close();
out.close();
}

// git merge-file --marker-size <marker_size> <current_path> <base_path> <other_path>
std::vector<std::string> command;
command.push_back("git");
command.push_back("merge-file");
command.push_back("-L");
command.push_back("ours");
command.push_back("-L");
command.push_back("base");
command.push_back("-L");
command.push_back("theirs");
command.push_back("--marker-size");
command.push_back(marker_size);
command.push_back(std::string(current_path) + ".tmp");
command.push_back(std::string(base_path) + ".tmp");
command.push_back(std::string(other_path) + ".tmp");
int ret = exit_status(exec_command(command));

// Run clean on output file
// We have to clean (encrypt) the output file because git runs smudge filter on it
// afterwards which would complain about the file not being encrypted.
{
std::ifstream in(std::string(current_path) + ".tmp", std::ifstream::binary);
if (!in) {
std::clog << "git-crypt: " << current_path << ".tmp: unable to open for reading" << std::endl;
return 1;
}
in.exceptions(std::ifstream::badbit);

std::ofstream out(current_path, std::ofstream::binary | std::ofstream::trunc);
if (!out) {
std::clog << "git-crypt: " << current_path << ": unable to open for writing" << std::endl;
return 1;
}
out.exceptions(std::ifstream::badbit);

if (clean(argi, argv, in, out) != 0) {
std::clog << "Error: failed to clean " << current_path << ": unable to merge file" << std::endl;
return 1;
}
in.close();
out.close();
}

// Clean-up temporary files
for (std::vector<std::string>::const_iterator file(smudge_files.begin()); file != smudge_files.end(); ++file) {
remove_file(*file + ".tmp");
}

return ret;
}

void help_init (std::ostream& out)
Expand Down
6 changes: 4 additions & 2 deletions commands.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

#include <string>
#include <iosfwd>
#include <iostream>

struct Error {
std::string message;
Expand All @@ -41,9 +42,10 @@ struct Error {
};

// Plumbing commands:
int clean (int argc, const char** argv);
int smudge (int argc, const char** argv);
int clean (int argc, const char** argv, std::istream& in = std::cin, std::ostream& out = std::cout);
int smudge (int argc, const char** argv, std::istream& in = std::cin, std::ostream& out = std::cout);
int diff (int argc, const char** argv);
int merge (int argc, const char** argv);
// Public commands:
int init (int argc, const char** argv);
int unlock (int argc, const char** argv);
Expand Down
2 changes: 1 addition & 1 deletion doc/multiple_keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ option to `git-crypt init` as follows:
To encrypt a file with an alternative key, use the `git-crypt-KEYNAME`
filter in `.gitattributes` as follows:

secretfile filter=git-crypt-KEYNAME diff=git-crypt-KEYNAME
secretfile filter=git-crypt-KEYNAME diff=git-crypt-KEYNAME merge=git-crypt-KEYNAME

To export an alternative key or share it with a GPG user, pass the `-k
KEYNAME` option to `git-crypt export-key` or `git-crypt add-gpg-user`
Expand Down
4 changes: 4 additions & 0 deletions git-crypt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ static void print_usage (std::ostream& out)
out << " clean [LEGACY-KEYFILE]" << std::endl;
out << " smudge [LEGACY-KEYFILE]" << std::endl;
out << " diff [LEGACY-KEYFILE] FILE" << std::endl;
out << " merge" << std::endl;
*/
out << std::endl;
out << "See 'git-crypt help COMMAND' for more information on a specific command." << std::endl;
Expand Down Expand Up @@ -231,6 +232,9 @@ try {
if (std::strcmp(command, "diff") == 0) {
return diff(argc, argv);
}
if (std::strcmp(command, "merge") == 0) {
return merge(argc, argv);
}
} catch (const Option_error& e) {
std::clog << "git-crypt: Error: " << e.option_name << ": " << e.message << std::endl;
help_for_command(command, std::clog);
Expand Down
12 changes: 6 additions & 6 deletions man/git-crypt.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
-->
<refentryinfo>
<title>git-crypt</title>
<date>2022-04-21</date>
<productname>git-crypt 0.7.0</productname>
<date>2024-11-11</date>
<productname>git-crypt 0.8.0</productname>

<author>
<othername>Andrew Ayer</othername>
Expand Down Expand Up @@ -310,11 +310,11 @@
<para>
Then, you specify the files to encrypt by creating a
<citerefentry><refentrytitle>gitattributes</refentrytitle><manvolnum>5</manvolnum></citerefentry> file.
Each file which you want to encrypt should be assigned the "<literal>filter=git-crypt diff=git-crypt</literal>"
Each file which you want to encrypt should be assigned the "<literal>filter=git-crypt diff=git-crypt merge=git-crypt</literal>"
attributes. For example:
</para>

<screen>secretfile filter=git-crypt diff=git-crypt&#10;*.key filter=git-crypt diff=git-crypt</screen>
<screen>secretfile filter=git-crypt diff=git-crypt merge=git-crypt&#10;*.key filter=git-crypt diff=git-crypt merge=git-crypt</screen>

<para>
Like a <filename>.gitignore</filename> file, <filename>.gitattributes</filename> files can match wildcards and
Expand Down Expand Up @@ -383,7 +383,7 @@
following in <filename>dir/.gitattributes</filename>:
</para>

<screen>* filter=git-crypt diff=git-crypt&#10;.gitattributes !filter !diff</screen>
<screen>* filter=git-crypt diff=git-crypt merge=git-crypt&#10;.gitattributes !filter !diff !merge</screen>

<para>
The second pattern is essential for ensuring that <filename>.gitattributes</filename> itself
Expand Down Expand Up @@ -414,7 +414,7 @@
filter in <filename>.gitattributes</filename> as follows:
</para>

<screen><replaceable>secretfile</replaceable> filter=git-crypt-<replaceable>KEYNAME</replaceable> diff=git-crypt-<replaceable>KEYNAME</replaceable></screen>
<screen><replaceable>secretfile</replaceable> filter=git-crypt-<replaceable>KEYNAME</replaceable> diff=git-crypt-<replaceable>KEYNAME</replaceable> merge=git-crypt-<replaceable>KEYNAME</replaceable></screen>

<para>
To export an alternative key or share it with a GPG user, pass the
Expand Down
Loading