use std::path::Path;
use anyhow::{
Context,
Result,
};
use bstr::ByteSlice;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum SigningFormat {
Gpg,
#[default]
Ssh,
}
#[derive(Debug, Clone, Default)]
pub struct SigningConfig {
pub enabled: bool,
pub format: SigningFormat,
pub signing_key: Option<String>,
}
pub fn read_signing_config(repo: &gix::Repository) -> SigningConfig {
let config = repo.config_snapshot();
let enabled = config.boolean("commit.gpgsign").unwrap_or(false);
let format = config
.string("gpg.format")
.map(|s| {
let s_str = s.to_str_lossy();
match s_str.as_ref() {
"openpgp" => SigningFormat::Gpg,
"ssh" => SigningFormat::Ssh,
_ => SigningFormat::default(),
}
})
.unwrap_or_default();
let signing_key = config.string("user.signingkey").map(|s| s.to_string());
SigningConfig {
enabled,
format,
signing_key,
}
}
pub fn sign_commit_payload(config: &SigningConfig, payload: &[u8]) -> Result<Option<Vec<u8>>> {
if !config.enabled {
return Ok(None);
}
let signing_key = match &config.signing_key {
Some(key) => key,
None => {
anyhow::bail!(
"Commit signing is enabled but no signing key is configured.\n\
Please set user.signingkey in git config:\n \
git config user.signingkey <key-path-or-id>"
);
}
};
let signature = match config.format {
SigningFormat::Ssh => sign_with_ssh(signing_key, payload)?,
SigningFormat::Gpg => {
anyhow::bail!(
"GPG signing is not yet implemented in cargo-version-info.\n\
Please use SSH signing instead:\n \
git config gpg.format ssh\n \
git config user.signingkey ~/.ssh/id_ed25519.pub"
);
}
};
Ok(Some(format_signature_for_header(&signature)))
}
fn sign_with_ssh(signing_key: &str, payload: &[u8]) -> Result<Vec<u8>> {
match sign_with_ssh_agent(signing_key, payload) {
Ok(sig) => return Ok(sig),
Err(agent_err) => {
eprintln!(
"SSH agent signing failed ({}), trying key file...",
agent_err
);
}
}
sign_with_ssh_file(signing_key, payload)
}
fn sign_with_ssh_agent(signing_key: &str, payload: &[u8]) -> Result<Vec<u8>> {
use ssh_agent_client_rs::Client;
use ssh_key::sha2::{
Digest,
Sha512,
};
use ssh_key::{
HashAlg,
SshSig,
};
let auth_sock =
std::env::var("SSH_AUTH_SOCK").context("SSH_AUTH_SOCK environment variable not set")?;
let mut client =
Client::connect(Path::new(&auth_sock)).context("Failed to connect to SSH agent")?;
let identities = client
.list_all_identities()
.context("Failed to list SSH agent identities")?;
let (identity_idx, public_key) = find_matching_identity(&identities, signing_key)?;
let namespace = "git";
let hash_alg = "sha512";
let message_hash = Sha512::digest(payload);
let mut blob = Vec::new();
blob.extend_from_slice(b"SSHSIG");
blob.extend_from_slice(&(namespace.len() as u32).to_be_bytes());
blob.extend_from_slice(namespace.as_bytes());
blob.extend_from_slice(&0u32.to_be_bytes());
blob.extend_from_slice(&(hash_alg.len() as u32).to_be_bytes());
blob.extend_from_slice(hash_alg.as_bytes());
blob.extend_from_slice(&(message_hash.len() as u32).to_be_bytes());
blob.extend_from_slice(&message_hash);
let identity = identities.into_iter().nth(identity_idx).unwrap();
let signature = client
.sign(identity, &blob)
.context("SSH agent signing failed")?;
let ssh_sig = SshSig::new(
public_key.key_data().clone(),
namespace,
HashAlg::Sha512,
signature,
)
.context("Failed to create SSH signature")?;
let pem = ssh_sig
.to_pem(ssh_key::LineEnding::LF)
.context("Failed to encode SSH signature as PEM")?;
Ok(pem.into_bytes())
}
fn find_matching_identity(
identities: &[ssh_agent_client_rs::Identity<'static>],
signing_key: &str,
) -> Result<(usize, ssh_key::PublicKey)> {
use ssh_agent_client_rs::Identity;
use ssh_key::PublicKey;
let target_fingerprint =
if signing_key.ends_with(".pub") || signing_key.contains('/') || signing_key.contains('\\')
{
let pub_key_path = if signing_key.ends_with(".pub") {
signing_key.to_string()
} else {
format!("{}.pub", signing_key)
};
if let Ok(pub_key) = PublicKey::read_openssh_file(Path::new(&pub_key_path)) {
Some(pub_key.fingerprint(ssh_key::HashAlg::Sha256))
} else {
None
}
} else {
None
};
for (idx, identity) in identities.iter().enumerate() {
let public_key: PublicKey = match identity {
Identity::PublicKey(pk) => pk.as_ref().clone().into_owned(),
Identity::Certificate(cert) => {
let cert_ref: &ssh_key::Certificate = &cert.as_ref().clone();
PublicKey::new(cert_ref.public_key().clone(), "")
}
};
let fingerprint = public_key.fingerprint(ssh_key::HashAlg::Sha256);
if let Some(ref target_fp) = target_fingerprint
&& fingerprint == *target_fp
{
return Ok((idx, public_key));
}
let fp_str = fingerprint.to_string();
if fp_str.contains(signing_key) || signing_key.contains(&fp_str) {
return Ok((idx, public_key));
}
let comment = public_key.comment();
if !comment.is_empty() && (comment.contains(signing_key) || signing_key.contains(comment)) {
return Ok((idx, public_key));
}
}
let available_keys: Vec<String> = identities
.iter()
.map(|identity| {
let pk: PublicKey = match identity {
Identity::PublicKey(pk) => pk.as_ref().clone().into_owned(),
Identity::Certificate(cert) => {
let cert_ref: &ssh_key::Certificate = &cert.as_ref().clone();
PublicKey::new(cert_ref.public_key().clone(), "")
}
};
let comment = pk.comment();
if comment.is_empty() {
pk.fingerprint(ssh_key::HashAlg::Sha256).to_string()
} else {
comment.to_string()
}
})
.collect();
anyhow::bail!(
"No matching SSH key found in agent for '{}'.\n\
Available keys: {:?}",
signing_key,
available_keys
);
}
fn sign_with_ssh_file(signing_key: &str, payload: &[u8]) -> Result<Vec<u8>> {
use ssh_key::{
HashAlg,
PrivateKey,
SshSig,
};
let private_key_path = if signing_key.ends_with(".pub") {
signing_key.trim_end_matches(".pub").to_string()
} else {
signing_key.to_string()
};
let private_key = PrivateKey::read_openssh_file(Path::new(&private_key_path))
.with_context(|| format!("Failed to read SSH private key from '{}'", private_key_path))?;
if private_key.is_encrypted() {
anyhow::bail!(
"SSH key '{}' is encrypted. Please use ssh-agent or an unencrypted key.\n\
Add the key to ssh-agent with: ssh-add {}",
private_key_path,
private_key_path
);
}
let ssh_sig = SshSig::sign(&private_key, "git", HashAlg::Sha512, payload)
.context("Failed to create SSH signature")?;
let pem = ssh_sig
.to_pem(ssh_key::LineEnding::LF)
.context("Failed to encode SSH signature as PEM")?;
Ok(pem.into_bytes())
}
fn format_signature_for_header(signature: &[u8]) -> Vec<u8> {
signature.to_vec()
}
pub fn build_commit_payload(
tree_id: &gix::ObjectId,
parent_id: gix::Id,
author: &gix::actor::Signature,
committer: &gix::actor::Signature,
message: &str,
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(b"tree ");
payload.extend_from_slice(tree_id.to_string().as_bytes());
payload.push(b'\n');
payload.extend_from_slice(b"parent ");
payload.extend_from_slice(parent_id.to_string().as_bytes());
payload.push(b'\n');
payload.extend_from_slice(b"author ");
write_signature(&mut payload, author);
payload.push(b'\n');
payload.extend_from_slice(b"committer ");
write_signature(&mut payload, committer);
payload.push(b'\n');
payload.push(b'\n');
payload.extend_from_slice(message.as_bytes());
payload
}
fn write_signature(buf: &mut Vec<u8>, sig: &gix::actor::Signature) {
buf.extend_from_slice(&sig.name);
buf.extend_from_slice(b" <");
buf.extend_from_slice(&sig.email);
buf.extend_from_slice(b"> ");
buf.extend_from_slice(sig.time.seconds.to_string().as_bytes());
buf.push(b' ');
let offset_minutes = sig.time.offset;
let sign = if offset_minutes >= 0 { '+' } else { '-' };
let abs_offset = offset_minutes.abs();
let hours = abs_offset / 60;
let minutes = abs_offset % 60;
buf.extend_from_slice(format!("{}{:02}{:02}", sign, hours, minutes).as_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signing_format_default() {
assert_eq!(SigningFormat::default(), SigningFormat::Ssh);
}
#[test]
fn test_signing_config_default() {
let config = SigningConfig::default();
assert!(!config.enabled);
assert_eq!(config.format, SigningFormat::Ssh);
assert!(config.signing_key.is_none());
}
#[test]
fn test_format_signature_for_header() {
let signature = b"-----BEGIN SSH SIGNATURE-----\nline1\nline2\n-----END SSH SIGNATURE-----";
let formatted = format_signature_for_header(signature);
assert_eq!(formatted, signature);
}
#[test]
fn test_write_signature_positive_offset() {
let mut buf = Vec::new();
let sig = gix::actor::Signature {
name: "Test User".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 1700000000,
offset: 60, },
};
write_signature(&mut buf, &sig);
let result = String::from_utf8_lossy(&buf);
assert!(result.contains("Test User <test@example.com>"));
assert!(result.contains("1700000000"));
assert!(result.contains("+0100"));
}
#[test]
fn test_write_signature_negative_offset() {
let mut buf = Vec::new();
let sig = gix::actor::Signature {
name: "Test User".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 1700000000,
offset: -300, },
};
write_signature(&mut buf, &sig);
let result = String::from_utf8_lossy(&buf);
assert!(result.contains("Test User <test@example.com>"));
assert!(result.contains("1700000000"));
assert!(result.contains("-0500"));
}
#[test]
fn test_sign_disabled() {
let config = SigningConfig::default();
let result = sign_commit_payload(&config, b"test payload").unwrap();
assert!(result.is_none());
}
#[test]
fn test_sign_enabled_no_key() {
let config = SigningConfig {
enabled: true,
format: SigningFormat::Ssh,
signing_key: None,
};
let result = sign_commit_payload(&config, b"test payload");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("signing key"));
}
#[test]
fn test_sign_gpg_not_implemented() {
let config = SigningConfig {
enabled: true,
format: SigningFormat::Gpg,
signing_key: Some("ABCD1234".to_string()),
};
let result = sign_commit_payload(&config, b"test payload");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("not yet implemented")
);
}
fn create_test_repo() -> (tempfile::TempDir, gix::Repository) {
use std::process::Command;
let temp_dir = tempfile::tempdir().expect("Failed to create temp directory");
let dir = temp_dir.path();
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.expect("Failed to run git init");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(dir)
.output()
.expect("Failed to set user.name");
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(dir)
.output()
.expect("Failed to set user.email");
Command::new("git")
.args(["config", "commit.gpgsign", "false"])
.current_dir(dir)
.output()
.expect("Failed to set commit.gpgsign");
Command::new("git")
.args(["config", "--unset", "user.signingkey"])
.current_dir(dir)
.output()
.ok();
let repo = gix::open_opts(dir, gix::open::Options::isolated())
.expect("Failed to open repo with gix");
(temp_dir, repo)
}
#[test]
#[serial_test::serial]
fn test_read_signing_config_no_signing() {
let (_temp_dir, repo) = create_test_repo();
let config = read_signing_config(&repo);
assert!(!config.enabled);
assert_eq!(config.format, SigningFormat::Ssh); assert!(config.signing_key.is_none());
}
#[test]
#[serial_test::serial]
fn test_read_signing_config_enabled_no_key() {
use std::process::Command;
let (temp_dir, _repo) = create_test_repo();
let dir = temp_dir.path();
Command::new("git")
.args(["config", "commit.gpgsign", "true"])
.current_dir(dir)
.output()
.expect("Failed to set commit.gpgsign");
let repo =
gix::open_opts(dir, gix::open::Options::isolated()).expect("Failed to reopen repo");
let config = read_signing_config(&repo);
assert!(config.enabled);
assert_eq!(config.format, SigningFormat::Ssh); assert!(config.signing_key.is_none());
}
#[test]
#[serial_test::serial]
fn test_read_signing_config_ssh() {
use std::process::Command;
let (temp_dir, _repo) = create_test_repo();
let dir = temp_dir.path();
Command::new("git")
.args(["config", "commit.gpgsign", "true"])
.current_dir(dir)
.output()
.expect("Failed to set commit.gpgsign");
Command::new("git")
.args(["config", "gpg.format", "ssh"])
.current_dir(dir)
.output()
.expect("Failed to set gpg.format");
Command::new("git")
.args(["config", "user.signingkey", "~/.ssh/id_ed25519.pub"])
.current_dir(dir)
.output()
.expect("Failed to set user.signingkey");
let repo =
gix::open_opts(dir, gix::open::Options::isolated()).expect("Failed to reopen repo");
let config = read_signing_config(&repo);
assert!(config.enabled);
assert_eq!(config.format, SigningFormat::Ssh);
assert_eq!(
config.signing_key,
Some("~/.ssh/id_ed25519.pub".to_string())
);
}
#[test]
#[serial_test::serial]
fn test_read_signing_config_gpg() {
use std::process::Command;
let (temp_dir, _repo) = create_test_repo();
let dir = temp_dir.path();
Command::new("git")
.args(["config", "commit.gpgsign", "true"])
.current_dir(dir)
.output()
.expect("Failed to set commit.gpgsign");
Command::new("git")
.args(["config", "gpg.format", "openpgp"])
.current_dir(dir)
.output()
.expect("Failed to set gpg.format");
Command::new("git")
.args(["config", "user.signingkey", "ABCD1234EFGH5678"])
.current_dir(dir)
.output()
.expect("Failed to set user.signingkey");
let repo =
gix::open_opts(dir, gix::open::Options::isolated()).expect("Failed to reopen repo");
let config = read_signing_config(&repo);
assert!(config.enabled);
assert_eq!(config.format, SigningFormat::Gpg);
assert_eq!(config.signing_key, Some("ABCD1234EFGH5678".to_string()));
}
#[test]
fn test_build_commit_payload() {
let tree_id = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
.expect("Invalid tree ID");
let parent_id = gix::ObjectId::from_hex(b"fedcba9876543210fedcba9876543210fedcba98")
.expect("Invalid parent ID");
let author = gix::actor::Signature {
name: "Test User".into(),
email: "test@example.com".into(),
time: gix::date::Time {
seconds: 1700000000,
offset: 0,
},
};
let committer = author.clone();
let message = "chore(version): bump 1.0.0 -> 1.0.1";
let repo = gix::open_opts(
std::env::current_dir().expect("No current dir"),
gix::open::Options::isolated(),
)
.expect("Failed to open repo");
let parent_id_ref = repo.find_object(parent_id);
if parent_id_ref.is_err() {
return;
}
let parent_id = parent_id_ref.unwrap().id();
let payload = build_commit_payload(&tree_id, parent_id, &author, &committer, message);
let payload_str = String::from_utf8_lossy(&payload);
assert!(payload_str.starts_with("tree 0123456789abcdef0123456789abcdef01234567\n"));
assert!(payload_str.contains("author Test User <test@example.com>"));
assert!(payload_str.contains("committer Test User <test@example.com>"));
assert!(payload_str.ends_with("chore(version): bump 1.0.0 -> 1.0.1"));
}
}