435 lines
16 KiB
Rust
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(×tamp);
|
|
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(())
|
|
}
|