git-vain/src/main.rs

435 lines
16 KiB
Rust

use std::borrow::Cow;
use std::fs::File;
use std::io::Write;
use std::process::Command;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicUsize, Ordering};
use anyhow::{Context as _, Result};
use clap::{Parser, ValueEnum};
use flate2::Compression;
use flate2::write::ZlibEncoder;
use git2::Repository;
use gpgme::{Context, Protocol, SignMode};
use indicatif::ProgressStyle;
use openpgp::{
packet::prelude::*,
parse::Parse,
serialize::{
Serialize,
stream::{Armorer, Message},
},
types::*,
};
use rand::Rng;
use sequoia_openpgp as openpgp;
use sequoia_openpgp::policy::StandardPolicy;
use sha1::{Digest, Sha1};
/// A vanity commit hash generator that's fast, works with PGP signing, and can
/// be used on arbitrary commits (not just HEAD).
///
/// This works by decrementing the commit time one second and recalculating the
/// hash until a hash matching your selected prefix is found.
///
/// If using PGP signing, the timestamp remains unchanged. Every PGP signature is different, even on
/// the same data, so decrementing the timestamp is no longer required.
/// Providing a key file is orders of magnitude faster than providing a key ID,
/// since using a key ID goes through gpg-agent, which is very slow.
#[derive(Parser)]
struct Cli {
/// The commit to replace with a vanity hash. Defaults to HEAD.
#[arg(short, long)]
commit: Option<String>,
/// The key ID to use for signing.
///
/// This is very slow compared to providing a key file, but this will use
/// gpg-agent.
#[arg(short = 'k', long)]
signing_key: Option<String>,
/// Path to an export secret key to use for signing. This is orders of
/// magnitude faster than providing a key ID, but it will not use gpg-agent
/// and requires you to provide your password to git-vain.
#[arg(short = 'K', long)]
signing_key_file: Option<String>,
/// The number of threads to use when searching for a matching hash.
/// Defaults to 0, and 0 will use the number of logical cores on your
/// system.
#[arg(short, long)]
threads: Option<usize>,
/// The method of changing the commit to generate a new hash. Defaults to
/// decrement.
///
/// Note that this has no effect if a signing key is provided.
///
/// - decrement will decrease the commit date by one second for each attempt
///
/// - increment will increase the commit date by one second for each attempt
///
/// - random will append a random string to the commit message for each
/// attempt
///
/// - counter will append an increasing number to the commit message for
/// each attempt
#[arg(short, long, value_enum, default_value_t = Method::Decrement)]
method: Method,
/// Force generating a new vanity hash for commits that already start with
/// the requested prefix.
#[arg(short, long)]
force: bool,
/// The vanity commit hash prefix you desire.
prefix: String,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Method {
Decrement,
Increment,
Random,
Counter,
}
fn main() -> Result<()> {
let args = Cli::parse();
if args.signing_key.is_some() && args.signing_key_file.is_some() {
anyhow::bail!("specify a key id or key file, not both");
}
let prefix = args.prefix.to_lowercase();
let commit = args.commit.as_deref().unwrap_or("HEAD");
let threads = match args.threads.unwrap_or(0) {
0 => num_cpus::get(),
x => x,
};
let mut seq_key = None;
if let Some(path) = &args.signing_key_file {
let key = openpgp::Cert::from_file(path)
.context("failed to read key from file")?;
let mut keys = Vec::new();
let p = &StandardPolicy::new();
for key in key
.keys().with_policy(p, None).alive().revoked(false).for_signing().secret()
.map(|ka| ka.key())
{
let key = if key.has_unencrypted_secret() {
key.clone().into_keypair()
.context("failed to convert unencrypted key into keypair")?
} else {
let mut first = true;
loop {
let mut prompt = inquire::Password::new("Key password: ");
if !first {
prompt = prompt.with_help_message("Failed to decrypt the secret key with that password");
}
let password = prompt.without_confirmation()
.prompt()?;
let decrypted = key.clone()
.decrypt_secret(&password.trim().into())
.context("failed to decrypt secret key");
first = false;
match decrypted {
Ok(d) => break d.into_keypair()
.context("failed to convert decrypted key into keypair")?,
Err(_) => continue,
}
}
};
keys.push(key);
}
if keys.is_empty() {
anyhow::bail!("no suitable signing keys in key file");
}
seq_key = keys.pop();
}
// first open the repo and find the commit pointed to by HEAD
let repo = Repository::open(".")
.context("no git repository in this directory")?;
let obj = repo.revparse_single(commit)
.context("could not find reference")?;
let commit = repo.find_commit(obj.id())
.context("could not resolve reference to a commit")?;
if !args.force && commit.id().to_string().starts_with(&prefix) {
anyhow::bail!("commit already starts with prefix");
}
// grab the header of the commit and the message
let raw_header = commit.raw_header()
.context("commit had an invalid header")?;
let mut header_lines = Vec::default();
// strip out the signature lines
{
let mut in_sig = false;
for line in raw_header.split('\n') {
if line.starts_with("gpgsig ") {
in_sig = true;
continue;
}
if in_sig {
if line.starts_with(' ') {
continue;
} else {
in_sig = false;
}
}
header_lines.push(line.to_string());
}
}
let message = commit.message_raw()
.context("commit had an invalid message")?
.to_string();
// find the author and committer
let author_idx = header_lines.iter()
.position(|line| line.starts_with("author "))
.context("missing author")?;
let committer_idx = header_lines.iter()
.position(|line| line.starts_with("committer "))
.context("missing committer")?;
// split the lines by spaces
let author_parts: Vec<_> = header_lines[author_idx].split(' ')
.map(ToOwned::to_owned)
.collect();
let committer_parts: Vec<_> = header_lines[committer_idx].split(' ')
.map(ToOwned::to_owned)
.collect();
// get the timestamp
let timestamp = author_parts.get(author_parts.len() - 2)
.context("missing timestamp")?
.parse::<i64>()
.context("invalid timestamp")?;
// let original = timestamp;
let author_len = author_parts.len();
let committer_len = committer_parts.len();
let counter = Arc::new(AtomicUsize::default());
let found = Arc::new(AtomicBool::new(false));
let timestamp = Arc::new(AtomicI64::new(timestamp));
let (commit_tx, commit_rx) = std::sync::mpsc::channel();
let bar = indicatif::ProgressBar::new_spinner()
.with_style(ProgressStyle::with_template("{spinner} {elapsed} - {human_len} hashes ({per_sec})")?);
for _ in 0..threads {
let bar = bar.clone();
let mut seq_key = seq_key.clone();
let key = args.signing_key.clone();
let counter = Arc::clone(&counter);
let found = Arc::clone(&found);
let timestamp = Arc::clone(&timestamp);
let message = message.clone();
let prefix = prefix.clone();
let mut author_parts = author_parts.clone();
let mut committer_parts = committer_parts.clone();
let mut header_lines = header_lines.clone();
let method = args.method;
let commit_tx = commit_tx.clone();
std::thread::spawn(move || {
let mut gpg = match key {
Some(key) => {
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
.context("could not create openpgp context")
.unwrap(); // FIXME
ctx.set_armor(true);
let key = ctx.get_secret_key(key)
.context("could not find secret key")
.unwrap(); // FIXME
ctx.add_signer(&key)
.context("could not add signer")
.unwrap(); // FIXME
Some((ctx, key))
}
None => None,
};
let mut sha1 = Sha1::default();
loop {
if found.load(Ordering::SeqCst) {
break;
}
let timestamp = if gpg.is_some() || seq_key.is_some() {
timestamp.load(Ordering::SeqCst)
} else {
match method {
Method::Decrement => timestamp.fetch_sub(1, Ordering::SeqCst) - 1,
Method::Increment => timestamp.fetch_add(1, Ordering::SeqCst) + 1,
_ => timestamp.load(Ordering::SeqCst),
}
};
let mut buffer = itoa::Buffer::new();
let append = if gpg.is_some() || seq_key.is_some() {
None
} else {
match method {
Method::Random => {
let mut bytes = [0; 16];
rand::thread_rng().fill(&mut bytes);
Some(data_encoding::HEXLOWER.encode(&bytes))
}
Method::Counter => {
let count = counter.fetch_add(1, Ordering::SeqCst) + 1;
Some(buffer.format(count).to_owned())
}
_ => None,
}
};
author_parts[author_len - 2] = buffer.format(timestamp).to_owned();
committer_parts[committer_len - 2] = buffer.format(timestamp).to_owned();
header_lines[author_idx] = author_parts.join(" ");
header_lines[committer_idx] = committer_parts.join(" ");
let mut header = header_lines.join("\n");
if let Some((ctx, _)) = &mut gpg {
let to_sign = format!("{header}\n{message}");
let mut output = Vec::new();
ctx.sign(SignMode::Detached, to_sign, &mut output)
.context("could not sign commit")
.unwrap(); // FIXME
let sig = String::from_utf8(output)
.context("signature was not utf-8")
.unwrap(); // FIXME
header.push_str("gpgsig");
for line in sig.trim().split('\n') {
header.push(' ');
header.push_str(line);
header.push('\n');
}
} else if let Some(key) = &mut seq_key {
let to_sign = format!("{header}\n{message}");
let sig = SignatureBuilder::new(SignatureType::Binary)
.sign_message(key, to_sign)
.context("failed to sign message")
.unwrap(); // FIXME
let mut output = Vec::new();
let message = Message::new(&mut output);
let mut message = Armorer::new(message)
.kind(openpgp::armor::Kind::Signature)
.build()
.context("failed to build pgp message")
.unwrap(); // FIXME
Packet::from(sig)
.serialize(&mut message)
.context("failed to serialise packet")
.unwrap(); // FIXME
message.finalize()
.context("could not finalise message")
.unwrap(); // FIXME
let sig = String::from_utf8(output)
.context("signature was not utf-8")
.unwrap(); // FIXME
header.push_str("gpgsig");
for line in sig.trim().split('\n') {
header.push(' ');
header.push_str(line);
header.push('\n');
}
}
let message = match append {
Some(append) => Cow::from(format!("{message}\n{append}\n")),
None => Cow::from(&message),
};
let total = format!("{header}\n{message}");
let raw_commit = format!("commit {}\0", total.len());
sha1.update(&raw_commit);
sha1.update(&total);
let hash = sha1.finalize_reset();
let hash = data_encoding::HEXLOWER.encode(&hash);
bar.inc(1);
if hash.starts_with(&prefix) {
found.store(true, Ordering::SeqCst);
commit_tx.send((hash, format!("{raw_commit}{total}"))).unwrap();
break;
}
}
});
}
let (hash, new_commit_data) = commit_rx.recv().unwrap();
bar.finish_and_clear();
let path = repo.path();
// let old_commit_id = commit.id().to_string();
// let old_path = path.join("objects")
// .join(&old_commit_id[..2])
// .join(&old_commit_id[2..]);
let new_path = path.join("objects")
.join(&hash[..2])
.join(&hash[2..]);
{
std::fs::create_dir_all(new_path.parent().unwrap())
.context("could not create directory")?;
let file = File::create(new_path)
.context("could not create commit file")?;
let mut compressor = ZlibEncoder::new(file, Compression::fast());
compressor.write_all(new_commit_data.as_bytes())
.context("could not write commit")?;
}
// let new_commit = repo.find_annotated_commit(Oid::from_str(&hash)?)
// .context("cannot get new commit")?;
// let new_new_commit = repo.find_commit(Oid::from_str(&hash)?)
// .context("cannot get new commit (2)")?;
// let old_commit = repo.revparse_single(&format!("{}~1", commit.id()))
// .context("cannot find old commit")?;
// let old_commit = repo.find_annotated_commit(old_commit.id())
// .context("cannot get new commit")?;
// let mut rebase = repo.rebase(None, Some(&old_commit), Some(&new_commit), None)?;
// while let Some(op) = rebase.next() {
// op?;
// rebase.commit(None, &new_new_commit.committer(), None)?;
// }
// rebase.finish(None)?;
let previous = format!("{}~1", commit.id());
let is_root = repo.revparse_single(&previous).is_err();
let final_arg = if is_root {
"--root"
} else {
&*previous
};
Command::new("git")
.args(["rebase", "--onto", &hash, final_arg])
.spawn()
.context("could not spawn rebase command")?
.wait()
.context("rebase command failed")?;
eprintln!("{hash}");
Ok(())
}