mirror of
https://github.com/AGWA/git-crypt.git
synced 2025-12-22 23:26:11 -08:00
Storing the key name in the key file makes it unnecessary to pass the --key-name option to git-crypt unlock. This breaks compatibility with post-revamp keys. On the plus side, keys are now extensible so in the future it will be easier to make changes to the format without breaking compatibility.
1173 lines
39 KiB
C++
1173 lines
39 KiB
C++
/*
|
|
* Copyright 2012, 2014 Andrew Ayer
|
|
*
|
|
* This file is part of git-crypt.
|
|
*
|
|
* git-crypt is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* git-crypt is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with git-crypt. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
* Additional permission under GNU GPL version 3 section 7:
|
|
*
|
|
* If you modify the Program, or any covered work, by linking or
|
|
* combining it with the OpenSSL project's OpenSSL library (or a
|
|
* modified version of that library), containing parts covered by the
|
|
* terms of the OpenSSL or SSLeay licenses, the licensors of the Program
|
|
* grant you additional permission to convey the resulting work.
|
|
* Corresponding Source for a non-source form of such a combination
|
|
* shall include the source code for the parts of OpenSSL used as well
|
|
* as that of the covered work.
|
|
*/
|
|
|
|
#include "commands.hpp"
|
|
#include "crypto.hpp"
|
|
#include "util.hpp"
|
|
#include "key.hpp"
|
|
#include "gpg.hpp"
|
|
#include "parse_options.hpp"
|
|
#include <unistd.h>
|
|
#include <stdint.h>
|
|
#include <algorithm>
|
|
#include <string>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <iostream>
|
|
#include <cstddef>
|
|
#include <cstring>
|
|
#include <cctype>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <errno.h>
|
|
#include <vector>
|
|
|
|
static void git_config (const std::string& name, const std::string& value)
|
|
{
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("config");
|
|
command.push_back(name);
|
|
command.push_back(value);
|
|
|
|
if (!successful_exit(exec_command(command))) {
|
|
throw Error("'git config' failed");
|
|
}
|
|
}
|
|
|
|
static void configure_git_filters (const char* key_name)
|
|
{
|
|
std::string escaped_git_crypt_path(escape_shell_arg(our_exe_path()));
|
|
|
|
if (key_name) {
|
|
// Note: key_name contains only shell-safe characters so it need not be escaped.
|
|
git_config(std::string("filter.git-crypt-") + key_name + ".smudge",
|
|
escaped_git_crypt_path + " smudge --key-name=" + key_name);
|
|
git_config(std::string("filter.git-crypt-") + key_name + ".clean",
|
|
escaped_git_crypt_path + " clean --key-name=" + key_name);
|
|
git_config(std::string("diff.git-crypt-") + key_name + ".textconv",
|
|
escaped_git_crypt_path + " diff --key-name=" + key_name);
|
|
} 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("diff.git-crypt.textconv", escaped_git_crypt_path + " diff");
|
|
}
|
|
}
|
|
|
|
static void validate_key_name_or_throw (const char* key_name)
|
|
{
|
|
std::string reason;
|
|
if (!validate_key_name(key_name, &reason)) {
|
|
throw Error(reason);
|
|
}
|
|
}
|
|
|
|
static std::string get_internal_key_path (const char* key_name)
|
|
{
|
|
// git rev-parse --git-dir
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("rev-parse");
|
|
command.push_back("--git-dir");
|
|
|
|
std::stringstream output;
|
|
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git rev-parse --git-dir' failed - is this a Git repository?");
|
|
}
|
|
|
|
std::string path;
|
|
std::getline(output, path);
|
|
path += "/git-crypt/keys/";
|
|
path += key_name ? key_name : "default";
|
|
return path;
|
|
}
|
|
|
|
static std::string get_repo_keys_path ()
|
|
{
|
|
// git rev-parse --show-toplevel
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("rev-parse");
|
|
command.push_back("--show-toplevel");
|
|
|
|
std::stringstream output;
|
|
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git rev-parse --show-toplevel' failed - is this a Git repository?");
|
|
}
|
|
|
|
std::string path;
|
|
std::getline(output, path);
|
|
|
|
if (path.empty()) {
|
|
// could happen for a bare repo
|
|
throw Error("Could not determine Git working tree - is this a non-bare repo?");
|
|
}
|
|
|
|
path += "/.git-crypt/keys";
|
|
return path;
|
|
}
|
|
|
|
static std::string get_path_to_top ()
|
|
{
|
|
// git rev-parse --show-cdup
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("rev-parse");
|
|
command.push_back("--show-cdup");
|
|
|
|
std::stringstream output;
|
|
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git rev-parse --show-cdup' failed - is this a Git repository?");
|
|
}
|
|
|
|
std::string path_to_top;
|
|
std::getline(output, path_to_top);
|
|
|
|
return path_to_top;
|
|
}
|
|
|
|
static void get_git_status (std::ostream& output)
|
|
{
|
|
// git status -uno --porcelain
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("status");
|
|
command.push_back("-uno"); // don't show untracked files
|
|
command.push_back("--porcelain");
|
|
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git status' failed - is this a Git repository?");
|
|
}
|
|
}
|
|
|
|
static bool check_if_head_exists ()
|
|
{
|
|
// git rev-parse HEAD
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("rev-parse");
|
|
command.push_back("HEAD");
|
|
|
|
std::stringstream output;
|
|
return successful_exit(exec_command(command, output));
|
|
}
|
|
|
|
// returns filter and diff attributes as a pair
|
|
static std::pair<std::string, std::string> get_file_attributes (const std::string& filename)
|
|
{
|
|
// git check-attr filter diff -- filename
|
|
// TODO: pass -z to get machine-parseable output (this requires Git 1.8.5 or higher, which was released on 27 Nov 2013)
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("check-attr");
|
|
command.push_back("filter");
|
|
command.push_back("diff");
|
|
command.push_back("--");
|
|
command.push_back(filename);
|
|
|
|
std::stringstream output;
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git check-attr' failed - is this a Git repository?");
|
|
}
|
|
|
|
std::string filter_attr;
|
|
std::string diff_attr;
|
|
|
|
std::string line;
|
|
// Example output:
|
|
// filename: filter: git-crypt
|
|
// filename: diff: git-crypt
|
|
while (std::getline(output, line)) {
|
|
// filename might contain ": ", so parse line backwards
|
|
// filename: attr_name: attr_value
|
|
// ^name_pos ^value_pos
|
|
const std::string::size_type value_pos(line.rfind(": "));
|
|
if (value_pos == std::string::npos || value_pos == 0) {
|
|
continue;
|
|
}
|
|
const std::string::size_type name_pos(line.rfind(": ", value_pos - 1));
|
|
if (name_pos == std::string::npos) {
|
|
continue;
|
|
}
|
|
|
|
const std::string attr_name(line.substr(name_pos + 2, value_pos - (name_pos + 2)));
|
|
const std::string attr_value(line.substr(value_pos + 2));
|
|
|
|
if (attr_value != "unspecified" && attr_value != "unset" && attr_value != "set") {
|
|
if (attr_name == "filter") {
|
|
filter_attr = attr_value;
|
|
} else if (attr_name == "diff") {
|
|
diff_attr = attr_value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return std::make_pair(filter_attr, diff_attr);
|
|
}
|
|
|
|
static bool check_if_blob_is_encrypted (const std::string& object_id)
|
|
{
|
|
// git cat-file blob object_id
|
|
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("cat-file");
|
|
command.push_back("blob");
|
|
command.push_back(object_id);
|
|
|
|
// TODO: do this more efficiently - don't read entire command output into buffer, only read what we need
|
|
std::stringstream output;
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git cat-file' failed - is this a Git repository?");
|
|
}
|
|
|
|
char header[10];
|
|
output.read(header, sizeof(header));
|
|
return output.gcount() == sizeof(header) && std::memcmp(header, "\0GITCRYPT\0", 10) == 0;
|
|
}
|
|
|
|
static bool check_if_file_is_encrypted (const std::string& filename)
|
|
{
|
|
// git ls-files -sz filename
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("ls-files");
|
|
command.push_back("-sz");
|
|
command.push_back("--");
|
|
command.push_back(filename);
|
|
|
|
std::stringstream output;
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git ls-files' failed - is this a Git repository?");
|
|
}
|
|
|
|
if (output.peek() == -1) {
|
|
return false;
|
|
}
|
|
|
|
std::string mode;
|
|
std::string object_id;
|
|
output >> mode >> object_id;
|
|
|
|
return check_if_blob_is_encrypted(object_id);
|
|
}
|
|
|
|
static void load_key (Key_file& key_file, const char* key_name, const char* key_path =0, const char* legacy_path =0)
|
|
{
|
|
if (legacy_path) {
|
|
std::ifstream key_file_in(legacy_path, std::fstream::binary);
|
|
if (!key_file_in) {
|
|
throw Error(std::string("Unable to open key file: ") + legacy_path);
|
|
}
|
|
key_file.load_legacy(key_file_in);
|
|
} else if (key_path) {
|
|
std::ifstream key_file_in(key_path, std::fstream::binary);
|
|
if (!key_file_in) {
|
|
throw Error(std::string("Unable to open key file: ") + key_path);
|
|
}
|
|
key_file.load(key_file_in);
|
|
} else {
|
|
std::ifstream key_file_in(get_internal_key_path(key_name).c_str(), std::fstream::binary);
|
|
if (!key_file_in) {
|
|
// TODO: include key name in error message
|
|
throw Error("Unable to open key file - have you unlocked/initialized this repository yet?");
|
|
}
|
|
key_file.load(key_file_in);
|
|
}
|
|
}
|
|
|
|
static bool decrypt_repo_key (Key_file& key_file, const char* key_name, uint32_t key_version, const std::vector<std::string>& secret_keys, const std::string& keys_path)
|
|
{
|
|
for (std::vector<std::string>::const_iterator seckey(secret_keys.begin()); seckey != secret_keys.end(); ++seckey) {
|
|
std::ostringstream path_builder;
|
|
path_builder << keys_path << '/' << (key_name ? key_name : "default") << '/' << key_version << '/' << *seckey << ".gpg";
|
|
std::string path(path_builder.str());
|
|
if (access(path.c_str(), F_OK) == 0) {
|
|
std::stringstream decrypted_contents;
|
|
gpg_decrypt_from_file(path, decrypted_contents);
|
|
Key_file this_version_key_file;
|
|
this_version_key_file.load(decrypted_contents);
|
|
const Key_file::Entry* this_version_entry = this_version_key_file.get(key_version);
|
|
if (!this_version_entry) {
|
|
throw Error("GPG-encrypted keyfile is malformed because it does not contain expected key version");
|
|
}
|
|
key_file.add(*this_version_entry);
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static void encrypt_repo_key (const char* key_name, const Key_file::Entry& key, const std::vector<std::string>& collab_keys, const std::string& keys_path, std::vector<std::string>* new_files)
|
|
{
|
|
std::string key_file_data;
|
|
{
|
|
Key_file this_version_key_file;
|
|
this_version_key_file.set_key_name(key_name);
|
|
this_version_key_file.add(key);
|
|
key_file_data = this_version_key_file.store_to_string();
|
|
}
|
|
|
|
for (std::vector<std::string>::const_iterator collab(collab_keys.begin()); collab != collab_keys.end(); ++collab) {
|
|
std::ostringstream path_builder;
|
|
path_builder << keys_path << '/' << (key_name ? key_name : "default") << '/' << key.version << '/' << *collab << ".gpg";
|
|
std::string path(path_builder.str());
|
|
|
|
if (access(path.c_str(), F_OK) == 0) {
|
|
continue;
|
|
}
|
|
|
|
mkdir_parent(path);
|
|
gpg_encrypt_to_file(path, *collab, key_file_data.data(), key_file_data.size());
|
|
new_files->push_back(path);
|
|
}
|
|
}
|
|
|
|
static int parse_plumbing_options (const char** key_name, const char** key_file, int argc, char** argv)
|
|
{
|
|
Options_list options;
|
|
options.push_back(Option_def("-k", key_name));
|
|
options.push_back(Option_def("--key-name", key_name));
|
|
options.push_back(Option_def("--key-file", key_file));
|
|
|
|
return parse_options(options, argc, argv);
|
|
}
|
|
|
|
|
|
|
|
// Encrypt contents of stdin and write to stdout
|
|
int clean (int argc, char** argv)
|
|
{
|
|
const char* key_name = 0;
|
|
const char* key_path = 0;
|
|
const char* legacy_key_path = 0;
|
|
|
|
int argi = parse_plumbing_options(&key_name, &key_path, argc, argv);
|
|
if (argc - argi == 0) {
|
|
} else if (!key_name && !key_path && argc - argi == 1) { // Deprecated - for compatibility with pre-0.4
|
|
legacy_key_path = argv[argi];
|
|
} else {
|
|
std::clog << "Usage: git-crypt clean [--key-name=NAME] [--key-file=PATH]" << std::endl;
|
|
return 2;
|
|
}
|
|
Key_file key_file;
|
|
load_key(key_file, key_name, key_path, legacy_key_path);
|
|
|
|
const Key_file::Entry* key = key_file.get_latest();
|
|
if (!key) {
|
|
std::clog << "git-crypt: error: key file is empty" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// Read the entire file
|
|
|
|
Hmac_sha1_state hmac(key->hmac_key, HMAC_KEY_LEN); // Calculate the file's SHA1 HMAC as we go
|
|
uint64_t file_size = 0; // Keep track of the length, make sure it doesn't get too big
|
|
std::string file_contents; // First 8MB or so of the file go here
|
|
temp_fstream temp_file; // The rest of the file spills into a temporary file on disk
|
|
temp_file.exceptions(std::fstream::badbit);
|
|
|
|
char buffer[1024];
|
|
|
|
while (std::cin && file_size < Aes_ctr_encryptor::MAX_CRYPT_BYTES) {
|
|
std::cin.read(buffer, sizeof(buffer));
|
|
|
|
const size_t bytes_read = std::cin.gcount();
|
|
|
|
hmac.add(reinterpret_cast<unsigned char*>(buffer), bytes_read);
|
|
file_size += bytes_read;
|
|
|
|
if (file_size <= 8388608) {
|
|
file_contents.append(buffer, bytes_read);
|
|
} else {
|
|
if (!temp_file.is_open()) {
|
|
temp_file.open(std::fstream::in | std::fstream::out | std::fstream::binary | std::fstream::app);
|
|
}
|
|
temp_file.write(buffer, bytes_read);
|
|
}
|
|
}
|
|
|
|
// Make sure the file isn't so large we'll overflow the counter value (which would doom security)
|
|
if (file_size >= Aes_ctr_encryptor::MAX_CRYPT_BYTES) {
|
|
std::clog << "git-crypt: error: file too long to encrypt securely" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// We use an HMAC of the file as the encryption nonce (IV) for CTR mode.
|
|
// By using a hash of the file we ensure that the encryption is
|
|
// deterministic so git doesn't think the file has changed when it really
|
|
// hasn't. CTR mode with a synthetic IV is provably semantically secure
|
|
// under deterministic CPA as long as the synthetic IV is derived from a
|
|
// secure PRF applied to the message. Since HMAC-SHA1 is a secure PRF, this
|
|
// encryption scheme is semantically secure under deterministic CPA.
|
|
//
|
|
// Informally, consider that if a file changes just a tiny bit, the IV will
|
|
// be completely different, resulting in a completely different ciphertext
|
|
// that leaks no information about the similarities of the plaintexts. Also,
|
|
// since we're using the output from a secure hash function plus a counter
|
|
// as the input to our block cipher, we should never have a situation where
|
|
// two different plaintext blocks get encrypted with the same CTR value. A
|
|
// nonce will be reused only if the entire file is the same, which leaks no
|
|
// information except that the files are the same.
|
|
//
|
|
// To prevent an attacker from building a dictionary of hash values and then
|
|
// looking up the nonce (which must be stored in the clear to allow for
|
|
// decryption), we use an HMAC as opposed to a straight hash.
|
|
|
|
// Note: Hmac_sha1_state::LEN >= Aes_ctr_encryptor::NONCE_LEN
|
|
|
|
unsigned char digest[Hmac_sha1_state::LEN];
|
|
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
|
|
|
|
// Now encrypt the file and write to stdout
|
|
Aes_ctr_encryptor aes(key->aes_key, digest);
|
|
|
|
// First read from the in-memory copy
|
|
const unsigned char* file_data = reinterpret_cast<const unsigned char*>(file_contents.data());
|
|
size_t file_data_len = file_contents.size();
|
|
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);
|
|
file_data += buffer_len;
|
|
file_data_len -= buffer_len;
|
|
}
|
|
|
|
// Then read from the temporary file if applicable
|
|
if (temp_file.is_open()) {
|
|
temp_file.seekg(0);
|
|
while (temp_file.peek() != -1) {
|
|
temp_file.read(buffer, sizeof(buffer));
|
|
|
|
const size_t buffer_len = temp_file.gcount();
|
|
|
|
aes.process(reinterpret_cast<unsigned char*>(buffer),
|
|
reinterpret_cast<unsigned char*>(buffer),
|
|
buffer_len);
|
|
std::cout.write(buffer, buffer_len);
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
// Decrypt contents of stdin and write to stdout
|
|
int smudge (int argc, char** argv)
|
|
{
|
|
const char* key_name = 0;
|
|
const char* key_path = 0;
|
|
const char* legacy_key_path = 0;
|
|
|
|
int argi = parse_plumbing_options(&key_name, &key_path, argc, argv);
|
|
if (argc - argi == 0) {
|
|
} else if (!key_name && !key_path && argc - argi == 1) { // Deprecated - for compatibility with pre-0.4
|
|
legacy_key_path = argv[argi];
|
|
} else {
|
|
std::clog << "Usage: git-crypt smudge [--key-name=NAME] [--key-file=PATH]" << std::endl;
|
|
return 2;
|
|
}
|
|
Key_file key_file;
|
|
load_key(key_file, key_name, key_path, legacy_key_path);
|
|
|
|
// 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) {
|
|
std::clog << "git-crypt: error: file not encrypted" << std::endl;
|
|
return 1;
|
|
}
|
|
const unsigned char* nonce = header + 10;
|
|
uint32_t key_version = 0; // TODO: get the version from the file header
|
|
|
|
const Key_file::Entry* key = key_file.get(key_version);
|
|
if (!key) {
|
|
std::clog << "git-crypt: error: key version " << key_version << " not available - please unlock with the latest version of the key." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
Aes_ctr_decryptor::process_stream(std::cin, std::cout, key->aes_key, nonce);
|
|
return 0;
|
|
}
|
|
|
|
int diff (int argc, char** argv)
|
|
{
|
|
const char* key_name = 0;
|
|
const char* key_path = 0;
|
|
const char* filename = 0;
|
|
const char* legacy_key_path = 0;
|
|
|
|
int argi = parse_plumbing_options(&key_name, &key_path, argc, argv);
|
|
if (argc - argi == 1) {
|
|
filename = argv[argi];
|
|
} else if (!key_name && !key_path && argc - argi == 2) { // Deprecated - for compatibility with pre-0.4
|
|
legacy_key_path = argv[argi];
|
|
filename = argv[argi + 1];
|
|
} else {
|
|
std::clog << "Usage: git-crypt diff [--key-name=NAME] [--key-file=PATH] FILENAME" << std::endl;
|
|
return 2;
|
|
}
|
|
Key_file key_file;
|
|
load_key(key_file, key_name, key_path, legacy_key_path);
|
|
|
|
// Open the file
|
|
std::ifstream in(filename, std::fstream::binary);
|
|
if (!in) {
|
|
std::clog << "git-crypt: " << filename << ": unable to open for reading" << std::endl;
|
|
return 1;
|
|
}
|
|
in.exceptions(std::fstream::badbit);
|
|
|
|
// Read the header to get the nonce and determine if it's actually encrypted
|
|
unsigned char header[10 + Aes_ctr_decryptor::NONCE_LEN];
|
|
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::cout.write(reinterpret_cast<char*>(header), in.gcount()); // don't forget to include the header which we read!
|
|
std::cout << in.rdbuf();
|
|
return 0;
|
|
}
|
|
|
|
// Go ahead and decrypt it
|
|
const unsigned char* nonce = header + 10;
|
|
uint32_t key_version = 0; // TODO: get the version from the file header
|
|
|
|
const Key_file::Entry* key = key_file.get(key_version);
|
|
if (!key) {
|
|
std::clog << "git-crypt: error: key version " << key_version << " not available - please unlock with the latest version of the key." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
Aes_ctr_decryptor::process_stream(in, std::cout, key->aes_key, nonce);
|
|
return 0;
|
|
}
|
|
|
|
int init (int argc, char** argv)
|
|
{
|
|
const char* key_name = 0;
|
|
Options_list options;
|
|
options.push_back(Option_def("-k", &key_name));
|
|
options.push_back(Option_def("--key-name", &key_name));
|
|
|
|
int argi = parse_options(options, argc, argv);
|
|
|
|
if (!key_name && argc - argi == 1) {
|
|
std::clog << "Warning: 'git-crypt init' with a key file is deprecated as of git-crypt 0.4" << std::endl;
|
|
std::clog << "and will be removed in a future release. Please get in the habit of using" << std::endl;
|
|
std::clog << "'git-crypt unlock KEYFILE' instead." << std::endl;
|
|
return unlock(argc, argv);
|
|
}
|
|
if (argc - argi != 0) {
|
|
std::clog << "Usage: git-crypt init [-k KEYNAME]" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
if (key_name) {
|
|
validate_key_name_or_throw(key_name);
|
|
}
|
|
|
|
std::string internal_key_path(get_internal_key_path(key_name));
|
|
if (access(internal_key_path.c_str(), F_OK) == 0) {
|
|
// TODO: add a -f option to reinitialize the repo anyways (this should probably imply a refresh)
|
|
// TODO: include key_name in error message
|
|
std::clog << "Error: this repository has already been initialized with git-crypt." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// 1. Generate a key and install it
|
|
std::clog << "Generating key..." << std::endl;
|
|
Key_file key_file;
|
|
key_file.set_key_name(key_name);
|
|
key_file.generate();
|
|
|
|
mkdir_parent(internal_key_path);
|
|
if (!key_file.store_to_file(internal_key_path.c_str())) {
|
|
std::clog << "Error: " << internal_key_path << ": unable to write key file" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// 2. Configure git for git-crypt
|
|
configure_git_filters(key_name);
|
|
|
|
return 0;
|
|
}
|
|
|
|
int unlock (int argc, char** argv)
|
|
{
|
|
const char* symmetric_key_file = 0;
|
|
const char* key_name = 0;
|
|
Options_list options;
|
|
options.push_back(Option_def("-k", &key_name));
|
|
options.push_back(Option_def("--key-name", &key_name));
|
|
|
|
int argi = parse_options(options, argc, argv);
|
|
if (argc - argi == 0) {
|
|
} else if (argc - argi == 1) {
|
|
symmetric_key_file = argv[argi];
|
|
} else {
|
|
std::clog << "Usage: git-crypt unlock [-k KEYNAME] [KEYFILE]" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
// 0. Make sure working directory is clean (ignoring untracked files)
|
|
// We do this because we run 'git checkout -f HEAD' later and we don't
|
|
// want the user to lose any changes. 'git checkout -f HEAD' doesn't touch
|
|
// untracked files so it's safe to ignore those.
|
|
|
|
// Running 'git status' also serves as a check that the Git repo is accessible.
|
|
|
|
std::stringstream status_output;
|
|
get_git_status(status_output);
|
|
|
|
// 1. Check to see if HEAD exists. See below why we do this.
|
|
bool head_exists = check_if_head_exists();
|
|
|
|
if (status_output.peek() != -1 && head_exists) {
|
|
// We only care that the working directory is dirty if HEAD exists.
|
|
// If HEAD doesn't exist, we won't be resetting to it (see below) so
|
|
// it doesn't matter that the working directory is dirty.
|
|
std::clog << "Error: Working directory not clean." << std::endl;
|
|
std::clog << "Please commit your changes or 'git stash' them before running 'git-crypt' unlock." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// 2. Determine the path to the top of the repository. We pass this as the argument
|
|
// to 'git checkout' below. (Determine the path now so in case it fails we haven't already
|
|
// mucked with the git config.)
|
|
std::string path_to_top(get_path_to_top());
|
|
|
|
// 3. Install the key
|
|
Key_file key_file;
|
|
if (symmetric_key_file) {
|
|
// Read from the symmetric key file
|
|
// TODO: command line flag to accept legacy key format?
|
|
|
|
if (key_name) {
|
|
std::clog << "Error: key name should not be specified when unlocking with symmetric key." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
try {
|
|
if (std::strcmp(symmetric_key_file, "-") == 0) {
|
|
key_file.load(std::cin);
|
|
} else {
|
|
if (!key_file.load_from_file(symmetric_key_file)) {
|
|
std::clog << "Error: " << symmetric_key_file << ": unable to read key file" << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
} catch (Key_file::Incompatible) {
|
|
std::clog << "Error: " << symmetric_key_file << " is in an incompatible format" << std::endl;
|
|
std::clog << "Please upgrade to a newer version of git-crypt." << std::endl;
|
|
return 1;
|
|
} catch (Key_file::Malformed) {
|
|
std::clog << "Error: " << symmetric_key_file << ": not a valid git-crypt key file" << std::endl;
|
|
std::clog << "If this key was created prior to git-crypt 0.4, you need to migrate it" << std::endl;
|
|
std::clog << "by running 'git-crypt migrate-key /path/to/key/file'." << std::endl;
|
|
return 1;
|
|
}
|
|
} else {
|
|
// Decrypt GPG key from root of repo
|
|
std::string repo_keys_path(get_repo_keys_path());
|
|
std::vector<std::string> gpg_secret_keys(gpg_list_secret_keys());
|
|
// TODO: command-line option to specify the precise secret key to use
|
|
// TODO: don't hard code key version 0 here - instead, determine the most recent version and try to decrypt that, or decrypt all versions if command-line option specified
|
|
if (!decrypt_repo_key(key_file, key_name, 0, gpg_secret_keys, repo_keys_path)) {
|
|
std::clog << "Error: no GPG secret key available to unlock this repository." << std::endl;
|
|
std::clog << "To unlock with a shared symmetric key instead, specify the path to the symmetric key as an argument to 'git-crypt unlock'." << std::endl;
|
|
std::clog << "To see a list of GPG keys authorized to unlock this repository, run 'git-crypt ls-collabs'." << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
std::string internal_key_path(get_internal_key_path(key_file.get_key_name()));
|
|
// TODO: croak if internal_key_path already exists???
|
|
mkdir_parent(internal_key_path);
|
|
if (!key_file.store_to_file(internal_key_path.c_str())) {
|
|
std::clog << "Error: " << internal_key_path << ": unable to write key file" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// 4. Configure git for git-crypt
|
|
configure_git_filters(key_file.get_key_name());
|
|
|
|
// 5. Do a force checkout so any files that were previously checked out encrypted
|
|
// will now be checked out decrypted.
|
|
// If HEAD doesn't exist (perhaps because this repo doesn't have any files yet)
|
|
// just skip the checkout.
|
|
if (head_exists) {
|
|
// git checkout -f HEAD -- path/to/top
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("checkout");
|
|
command.push_back("-f");
|
|
command.push_back("HEAD");
|
|
command.push_back("--");
|
|
if (path_to_top.empty()) {
|
|
command.push_back(".");
|
|
} else {
|
|
command.push_back(path_to_top);
|
|
}
|
|
|
|
if (!successful_exit(exec_command(command))) {
|
|
std::clog << "Error: 'git checkout' failed" << std::endl;
|
|
std::clog << "git-crypt has been set up but existing encrypted files have not been decrypted" << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
int add_collab (int argc, char** argv)
|
|
{
|
|
const char* key_name = 0;
|
|
Options_list options;
|
|
options.push_back(Option_def("-k", &key_name));
|
|
options.push_back(Option_def("--key-name", &key_name));
|
|
|
|
int argi = parse_options(options, argc, argv);
|
|
if (argc - argi == 0) {
|
|
std::clog << "Usage: git-crypt add-collab [-k KEYNAME] GPG_USER_ID [...]" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
// build a list of key fingerprints for every collaborator specified on the command line
|
|
std::vector<std::string> collab_keys;
|
|
|
|
for (int i = argi; i < argc; ++i) {
|
|
std::vector<std::string> keys(gpg_lookup_key(argv[i]));
|
|
if (keys.empty()) {
|
|
std::clog << "Error: public key for '" << argv[i] << "' not found in your GPG keyring" << std::endl;
|
|
return 1;
|
|
}
|
|
if (keys.size() > 1) {
|
|
std::clog << "Error: more than one public key matches '" << argv[i] << "' - please be more specific" << std::endl;
|
|
return 1;
|
|
}
|
|
collab_keys.push_back(keys[0]);
|
|
}
|
|
|
|
// TODO: have a retroactive option to grant access to all key versions, not just the most recent
|
|
Key_file key_file;
|
|
load_key(key_file, key_name);
|
|
const Key_file::Entry* key = key_file.get_latest();
|
|
if (!key) {
|
|
std::clog << "Error: key file is empty" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
std::string keys_path(get_repo_keys_path());
|
|
std::vector<std::string> new_files;
|
|
|
|
encrypt_repo_key(key_name, *key, collab_keys, keys_path, &new_files);
|
|
|
|
// add/commit the new files
|
|
if (!new_files.empty()) {
|
|
// git add NEW_FILE ...
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("add");
|
|
command.push_back("--");
|
|
command.insert(command.end(), new_files.begin(), new_files.end());
|
|
if (!successful_exit(exec_command(command))) {
|
|
std::clog << "Error: 'git add' failed" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
// git commit ...
|
|
// TODO: add a command line option (-n perhaps) to inhibit committing
|
|
// TODO: include key_name in commit message
|
|
std::ostringstream commit_message_builder;
|
|
commit_message_builder << "Add " << collab_keys.size() << " git-crypt collaborator" << (collab_keys.size() != 1 ? "s" : "") << "\n\nNew collaborators:\n\n";
|
|
for (std::vector<std::string>::const_iterator collab(collab_keys.begin()); collab != collab_keys.end(); ++collab) {
|
|
commit_message_builder << '\t' << gpg_shorten_fingerprint(*collab) << ' ' << gpg_get_uid(*collab) << '\n';
|
|
}
|
|
|
|
// git commit -m MESSAGE NEW_FILE ...
|
|
command.clear();
|
|
command.push_back("git");
|
|
command.push_back("commit");
|
|
command.push_back("-m");
|
|
command.push_back(commit_message_builder.str());
|
|
command.push_back("--");
|
|
command.insert(command.end(), new_files.begin(), new_files.end());
|
|
|
|
if (!successful_exit(exec_command(command))) {
|
|
std::clog << "Error: 'git commit' failed" << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
int rm_collab (int argc, char** argv) // TODO
|
|
{
|
|
std::clog << "Error: rm-collab is not yet implemented." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
int ls_collabs (int argc, char** argv) // TODO
|
|
{
|
|
// Sketch:
|
|
// Scan the sub-directories in .git-crypt/keys, outputting something like this:
|
|
// ====
|
|
// Key version 0:
|
|
// 0x143DE9B3F7316900 Andrew Ayer <andrew@example.com>
|
|
// 0x4E386D9C9C61702F ???
|
|
// Key version 1:
|
|
// 0x143DE9B3F7316900 Andrew Ayer <andrew@example.com>
|
|
// 0x1727274463D27F40 John Smith <smith@example.com>
|
|
// 0x4E386D9C9C61702F ???
|
|
// ====
|
|
// To resolve a long hex ID, use a command like this:
|
|
// gpg --options /dev/null --fixed-list-mode --batch --with-colons --list-keys 0x143DE9B3F7316900
|
|
|
|
std::clog << "Error: ls-collabs is not yet implemented." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
int export_key (int argc, char** argv)
|
|
{
|
|
// TODO: provide options to export only certain key versions
|
|
const char* key_name = 0;
|
|
Options_list options;
|
|
options.push_back(Option_def("-k", &key_name));
|
|
options.push_back(Option_def("--key-name", &key_name));
|
|
|
|
int argi = parse_options(options, argc, argv);
|
|
|
|
if (argc - argi != 1) {
|
|
std::clog << "Usage: git-crypt export-key [-k KEYNAME] FILENAME" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
Key_file key_file;
|
|
load_key(key_file, key_name);
|
|
|
|
const char* out_file_name = argv[argi];
|
|
|
|
if (std::strcmp(out_file_name, "-") == 0) {
|
|
key_file.store(std::cout);
|
|
} else {
|
|
if (!key_file.store_to_file(out_file_name)) {
|
|
std::clog << "Error: " << out_file_name << ": unable to write key file" << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
int keygen (int argc, char** argv)
|
|
{
|
|
if (argc != 1) {
|
|
std::clog << "Usage: git-crypt keygen KEYFILE" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
const char* key_file_name = argv[0];
|
|
|
|
if (std::strcmp(key_file_name, "-") != 0 && access(key_file_name, F_OK) == 0) {
|
|
std::clog << key_file_name << ": File already exists" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
std::clog << "Generating key..." << std::endl;
|
|
Key_file key_file;
|
|
key_file.generate();
|
|
|
|
if (std::strcmp(key_file_name, "-") == 0) {
|
|
key_file.store(std::cout);
|
|
} else {
|
|
if (!key_file.store_to_file(key_file_name)) {
|
|
std::clog << "Error: " << key_file_name << ": unable to write key file" << std::endl;
|
|
return 1;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int migrate_key (int argc, char** argv)
|
|
{
|
|
if (argc != 1) {
|
|
std::clog << "Usage: git-crypt migrate-key KEYFILE" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
const char* key_file_name = argv[0];
|
|
Key_file key_file;
|
|
|
|
try {
|
|
if (std::strcmp(key_file_name, "-") == 0) {
|
|
key_file.load_legacy(std::cin);
|
|
key_file.store(std::cout);
|
|
} else {
|
|
std::ifstream in(key_file_name, std::fstream::binary);
|
|
if (!in) {
|
|
std::clog << "Error: " << key_file_name << ": unable to open for reading" << std::endl;
|
|
return 1;
|
|
}
|
|
key_file.load_legacy(in);
|
|
in.close();
|
|
|
|
std::string new_key_file_name(key_file_name);
|
|
new_key_file_name += ".new";
|
|
|
|
if (access(new_key_file_name.c_str(), F_OK) == 0) {
|
|
std::clog << new_key_file_name << ": File already exists" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
if (!key_file.store_to_file(new_key_file_name.c_str())) {
|
|
std::clog << "Error: " << new_key_file_name << ": unable to write key file" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
if (util_rename(new_key_file_name.c_str(), key_file_name) == -1) {
|
|
std::clog << "Error: " << key_file_name << ": " << strerror(errno) << std::endl;
|
|
unlink(new_key_file_name.c_str());
|
|
return 1;
|
|
}
|
|
}
|
|
} catch (Key_file::Malformed) {
|
|
std::clog << "Error: " << key_file_name << ": not a valid legacy git-crypt key file" << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
int refresh (int argc, char** argv) // TODO: do a force checkout, much like in unlock
|
|
{
|
|
std::clog << "Error: refresh is not yet implemented." << std::endl;
|
|
return 1;
|
|
}
|
|
|
|
int status (int argc, char** argv)
|
|
{
|
|
// Usage:
|
|
// git-crypt status -r [-z] Show repo status
|
|
// git-crypt status [-e | -u] [-z] [FILE ...] Show encrypted status of files
|
|
// git-crypt status -f Fix unencrypted blobs
|
|
|
|
// TODO: help option / usage output
|
|
|
|
bool repo_status_only = false; // -r show repo status only
|
|
bool show_encrypted_only = false; // -e show encrypted files only
|
|
bool show_unencrypted_only = false; // -u show unencrypted files only
|
|
bool fix_problems = false; // -f fix problems
|
|
bool machine_output = false; // -z machine-parseable output
|
|
|
|
Options_list options;
|
|
options.push_back(Option_def("-r", &repo_status_only));
|
|
options.push_back(Option_def("-e", &show_encrypted_only));
|
|
options.push_back(Option_def("-u", &show_unencrypted_only));
|
|
options.push_back(Option_def("-f", &fix_problems));
|
|
options.push_back(Option_def("--fix", &fix_problems));
|
|
options.push_back(Option_def("-z", &machine_output));
|
|
|
|
int argi = parse_options(options, argc, argv);
|
|
|
|
if (repo_status_only) {
|
|
if (show_encrypted_only || show_unencrypted_only) {
|
|
std::clog << "Error: -e and -u options cannot be used with -r" << std::endl;
|
|
return 2;
|
|
}
|
|
if (fix_problems) {
|
|
std::clog << "Error: -f option cannot be used with -r" << std::endl;
|
|
return 2;
|
|
}
|
|
if (argc - argi != 0) {
|
|
std::clog << "Error: filenames cannot be specified when -r is used" << std::endl;
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
if (show_encrypted_only && show_unencrypted_only) {
|
|
std::clog << "Error: -e and -u options are mutually exclusive" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
if (fix_problems && (show_encrypted_only || show_unencrypted_only)) {
|
|
std::clog << "Error: -e and -u options cannot be used with -f" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
if (machine_output) {
|
|
// TODO: implement machine-parseable output
|
|
std::clog << "Sorry, machine-parseable output is not yet implemented" << std::endl;
|
|
return 2;
|
|
}
|
|
|
|
if (argc - argi == 0) {
|
|
// TODO: check repo status:
|
|
// is it set up for git-crypt?
|
|
// which keys are unlocked?
|
|
// --> check for filter config (see configure_git_filters()) and corresponding internal key
|
|
|
|
if (repo_status_only) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// git ls-files -cotsz --exclude-standard ...
|
|
std::vector<std::string> command;
|
|
command.push_back("git");
|
|
command.push_back("ls-files");
|
|
command.push_back("-cotsz");
|
|
command.push_back("--exclude-standard");
|
|
command.push_back("--");
|
|
if (argc - argi == 0) {
|
|
const std::string path_to_top(get_path_to_top());
|
|
if (!path_to_top.empty()) {
|
|
command.push_back(path_to_top);
|
|
}
|
|
} else {
|
|
for (int i = argi; i < argc; ++i) {
|
|
command.push_back(argv[i]);
|
|
}
|
|
}
|
|
|
|
std::stringstream output;
|
|
if (!successful_exit(exec_command(command, output))) {
|
|
throw Error("'git ls-files' failed - is this a Git repository?");
|
|
}
|
|
|
|
// Output looks like (w/o newlines):
|
|
// ? .gitignore\0
|
|
// H 100644 06ec22e5ed0de9280731ef000a10f9c3fbc26338 0 afile\0
|
|
|
|
std::vector<std::string> files;
|
|
bool attribute_errors = false;
|
|
bool unencrypted_blob_errors = false;
|
|
unsigned int nbr_of_fixed_blobs = 0;
|
|
unsigned int nbr_of_fix_errors = 0;
|
|
|
|
while (output.peek() != -1) {
|
|
std::string tag;
|
|
std::string object_id;
|
|
std::string filename;
|
|
output >> tag;
|
|
if (tag != "?") {
|
|
std::string mode;
|
|
std::string stage;
|
|
output >> mode >> object_id >> stage;
|
|
}
|
|
output >> std::ws;
|
|
std::getline(output, filename, '\0');
|
|
|
|
// TODO: get file attributes en masse for efficiency... unfortunately this requires machine-parseable output from git check-attr to be workable, and this is only supported in Git 1.8.5 and above (released 27 Nov 2013)
|
|
const std::pair<std::string, std::string> file_attrs(get_file_attributes(filename));
|
|
|
|
if (file_attrs.first == "git-crypt") { // TODO: key_name support
|
|
// File is encrypted
|
|
const bool blob_is_unencrypted = !object_id.empty() && !check_if_blob_is_encrypted(object_id);
|
|
|
|
if (fix_problems && blob_is_unencrypted) {
|
|
if (access(filename.c_str(), F_OK) != 0) {
|
|
std::clog << "Error: " << filename << ": cannot stage encrypted version because not present in working tree - please 'git rm' or 'git checkout' it" << std::endl;
|
|
++nbr_of_fix_errors;
|
|
} else {
|
|
touch_file(filename);
|
|
std::vector<std::string> git_add_command;
|
|
git_add_command.push_back("git");
|
|
git_add_command.push_back("add");
|
|
git_add_command.push_back("--");
|
|
git_add_command.push_back(filename);
|
|
if (!successful_exit(exec_command(git_add_command))) {
|
|
throw Error("'git-add' failed");
|
|
}
|
|
if (check_if_file_is_encrypted(filename)) {
|
|
std::cout << filename << ": staged encrypted version" << std::endl;
|
|
++nbr_of_fixed_blobs;
|
|
} else {
|
|
std::clog << "Error: " << filename << ": still unencrypted even after staging" << std::endl;
|
|
++nbr_of_fix_errors;
|
|
}
|
|
}
|
|
} else if (!fix_problems && !show_unencrypted_only) {
|
|
std::cout << " encrypted: " << filename;
|
|
if (file_attrs.second != file_attrs.first) {
|
|
// but diff filter is not properly set
|
|
std::cout << " *** WARNING: diff=" << file_attrs.first << " attribute not set ***";
|
|
attribute_errors = true;
|
|
}
|
|
if (blob_is_unencrypted) {
|
|
// File not actually encrypted
|
|
std::cout << " *** WARNING: staged/committed version is NOT ENCRYPTED! ***";
|
|
unencrypted_blob_errors = true;
|
|
}
|
|
std::cout << std::endl;
|
|
}
|
|
} else {
|
|
// File not encrypted
|
|
if (!fix_problems && !show_encrypted_only) {
|
|
std::cout << "not encrypted: " << filename << std::endl;
|
|
}
|
|
}
|
|
}
|
|
|
|
int exit_status = 0;
|
|
|
|
if (attribute_errors) {
|
|
std::cout << std::endl;
|
|
std::cout << "Warning: one or more files has a git-crypt filter attribute but not a" << std::endl;
|
|
std::cout << "corresponding git-crypt diff attribute. For proper 'git diff' operation" << std::endl;
|
|
std::cout << "you should fix the .gitattributes file to specify the correct diff attribute." << std::endl;
|
|
std::cout << "Consult the git-crypt documentation for help." << std::endl;
|
|
exit_status = 1;
|
|
}
|
|
if (unencrypted_blob_errors) {
|
|
std::cout << std::endl;
|
|
std::cout << "Warning: one or more files is marked for encryption via .gitattributes but" << std::endl;
|
|
std::cout << "was staged and/or committed before the .gitattributes file was in effect." << std::endl;
|
|
std::cout << "Run 'git-crypt status' with the '-f' option to stage an encrypted version." << std::endl;
|
|
exit_status = 1;
|
|
}
|
|
if (nbr_of_fixed_blobs) {
|
|
std::cout << "Staged " << nbr_of_fixed_blobs << " encrypted file" << (nbr_of_fixed_blobs != 1 ? "s" : "") << "." << std::endl;
|
|
std::cout << "Warning: if these files were previously committed, unencrypted versions still exist in the repository's history." << std::endl;
|
|
}
|
|
if (nbr_of_fix_errors) {
|
|
std::cout << "Unable to stage " << nbr_of_fix_errors << " file" << (nbr_of_fix_errors != 1 ? "s" : "") << "." << std::endl;
|
|
exit_status = 1;
|
|
}
|
|
|
|
return exit_status;
|
|
}
|
|
|