mirror of
https://github.com/gitui-org/gitui
synced 2026-05-24 09:28:21 +00:00
442 lines
12 KiB
Rust
442 lines
12 KiB
Rust
//! Sign commit data.
|
|
|
|
use ssh_key::{HashAlg, LineEnding, PrivateKey};
|
|
use std::path::PathBuf;
|
|
|
|
/// Error type for [`SignBuilder`], used to create [`Sign`]'s
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum SignBuilderError {
|
|
/// The given format is invalid
|
|
#[error("Failed to derive a commit signing method from git configuration 'gpg.format': {0}")]
|
|
InvalidFormat(String),
|
|
|
|
/// The GPG signing key could
|
|
#[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
|
|
GPGSigningKey(String),
|
|
|
|
/// The SSH signing key could
|
|
#[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
|
|
SSHSigningKey(String),
|
|
|
|
/// No signing signature could be built from the configuration data present
|
|
#[error("Failed to build signing signature: {0}")]
|
|
Signature(String),
|
|
|
|
/// Failure on unimplemented signing methods
|
|
/// to be removed once all methods have been implemented
|
|
#[error("Select signing method '{0}' has not been implemented")]
|
|
MethodNotImplemented(String),
|
|
}
|
|
|
|
/// Error type for [`Sign`], used to sign data
|
|
#[derive(thiserror::Error, Debug)]
|
|
pub enum SignError {
|
|
/// Unable to spawn process
|
|
#[error("Failed to spawn signing process: {0}")]
|
|
Spawn(String),
|
|
|
|
/// Unable to acquire the child process' standard input to write the commit data for signing
|
|
#[error("Failed to acquire standard input handler")]
|
|
Stdin,
|
|
|
|
/// Unable to write commit data to sign to standard input of the child process
|
|
#[error("Failed to write buffer to standard input of signing process: {0}")]
|
|
WriteBuffer(String),
|
|
|
|
/// Unable to retrieve the signed data from the child process
|
|
#[error("Failed to get output of signing process call: {0}")]
|
|
Output(String),
|
|
|
|
/// Failure of the child process
|
|
#[error("Failed to execute signing process: {0}")]
|
|
Shellout(String),
|
|
}
|
|
|
|
/// Sign commit data using various methods
|
|
pub trait Sign {
|
|
/// Sign commit with the respective implementation.
|
|
///
|
|
/// Retrieve an implementation using [`SignBuilder::from_gitconfig`].
|
|
///
|
|
/// The `commit` buffer can be created using the following steps:
|
|
/// - create a buffer using [`git2::Repository::commit_create_buffer`]
|
|
///
|
|
/// The function returns a tuple of `signature` and `signature_field`.
|
|
/// These values can then be passed into [`git2::Repository::commit_signed`].
|
|
/// Finally, the repository head needs to be advanced to the resulting commit ID
|
|
/// using [`git2::Reference::set_target`].
|
|
fn sign(
|
|
&self,
|
|
commit: &[u8],
|
|
) -> Result<(String, Option<String>), SignError>;
|
|
|
|
/// only available in `#[cfg(test)]` helping to diagnose issues
|
|
#[cfg(test)]
|
|
fn program(&self) -> &String;
|
|
|
|
/// only available in `#[cfg(test)]` helping to diagnose issues
|
|
#[cfg(test)]
|
|
fn signing_key(&self) -> &String;
|
|
}
|
|
|
|
/// A builder to facilitate the creation of a signing method ([`Sign`]) by examining the git configuration.
|
|
pub struct SignBuilder;
|
|
|
|
impl SignBuilder {
|
|
/// Get a [`Sign`] from the given repository configuration to sign commit data
|
|
///
|
|
///
|
|
/// ```no_run
|
|
/// use asyncgit::sync::sign::SignBuilder;
|
|
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
///
|
|
/// /// Repo in a temporary directory for demonstration
|
|
/// let dir = std::env::temp_dir();
|
|
/// let repo = git2::Repository::init(dir)?;
|
|
///
|
|
/// /// Get the config from the repository
|
|
/// let config = repo.config()?;
|
|
///
|
|
/// /// Retrieve a `Sign` implementation
|
|
/// let sign = SignBuilder::from_gitconfig(&repo, &config)?;
|
|
/// # Ok(())
|
|
/// # }
|
|
/// ```
|
|
pub fn from_gitconfig(
|
|
repo: &git2::Repository,
|
|
config: &git2::Config,
|
|
) -> Result<Box<dyn Sign>, SignBuilderError> {
|
|
let format = config
|
|
.get_string("gpg.format")
|
|
.unwrap_or_else(|_| "openpgp".to_string());
|
|
|
|
// Variants are described in the git config documentation
|
|
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
|
|
match format.as_str() {
|
|
"openpgp" => {
|
|
// Try to retrieve the gpg program from the git configuration,
|
|
// moving from the least to the most specific config key,
|
|
// defaulting to "gpg" if nothing is explicitly defined (per git's implementation)
|
|
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
|
|
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
|
|
let program = config
|
|
.get_string("gpg.openpgp.program")
|
|
.or_else(|_| config.get_string("gpg.program"))
|
|
.unwrap_or_else(|_| "gpg".to_string());
|
|
|
|
// Optional signing key.
|
|
// If 'user.signingKey' is not set, we'll use 'user.name' and 'user.email'
|
|
// to build a default signature in the format 'name <email>'.
|
|
// https://git-scm.com/docs/git-config#Documentation/git-config.txt-usersigningKey
|
|
let signing_key = config
|
|
.get_string("user.signingKey")
|
|
.or_else(
|
|
|_| -> Result<String, SignBuilderError> {
|
|
Ok(crate::sync::commit::signature_allow_undefined_name(repo)
|
|
.map_err(|err| {
|
|
SignBuilderError::Signature(
|
|
err.to_string(),
|
|
)
|
|
})?
|
|
.to_string())
|
|
},
|
|
)
|
|
.map_err(|err| {
|
|
SignBuilderError::GPGSigningKey(
|
|
err.to_string(),
|
|
)
|
|
})?;
|
|
|
|
Ok(Box::new(GPGSign {
|
|
program,
|
|
signing_key,
|
|
}))
|
|
}
|
|
"x509" => Err(SignBuilderError::MethodNotImplemented(
|
|
String::from("x509"),
|
|
)),
|
|
"ssh" => {
|
|
let ssh_signer = config
|
|
.get_string("user.signingKey")
|
|
.ok()
|
|
.and_then(|key_path| {
|
|
key_path.strip_prefix('~').map_or_else(
|
|
|| Some(PathBuf::from(&key_path)),
|
|
|ssh_key_path| {
|
|
dirs::home_dir().map(|home| {
|
|
home.join(
|
|
ssh_key_path
|
|
.strip_prefix('/')
|
|
.unwrap_or(ssh_key_path),
|
|
)
|
|
})
|
|
},
|
|
)
|
|
})
|
|
.ok_or_else(|| {
|
|
SignBuilderError::SSHSigningKey(String::from(
|
|
"ssh key setting absent",
|
|
))
|
|
})
|
|
.and_then(SSHSign::new)?;
|
|
let signer: Box<dyn Sign> = Box::new(ssh_signer);
|
|
Ok(signer)
|
|
}
|
|
_ => Err(SignBuilderError::InvalidFormat(format)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sign commit data using `OpenPGP`
|
|
pub struct GPGSign {
|
|
program: String,
|
|
signing_key: String,
|
|
}
|
|
|
|
impl GPGSign {
|
|
/// Create new [`GPGSign`] using given program and signing key.
|
|
pub fn new(program: &str, signing_key: &str) -> Self {
|
|
Self {
|
|
program: program.to_string(),
|
|
signing_key: signing_key.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Sign for GPGSign {
|
|
fn sign(
|
|
&self,
|
|
commit: &[u8],
|
|
) -> Result<(String, Option<String>), SignError> {
|
|
use std::io::Write;
|
|
use std::process::{Command, Stdio};
|
|
|
|
let mut cmd = Command::new(&self.program);
|
|
cmd.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.arg("--status-fd=2")
|
|
.arg("-bsau")
|
|
.arg(&self.signing_key);
|
|
|
|
log::trace!("signing command: {cmd:?}");
|
|
|
|
let mut child = cmd
|
|
.spawn()
|
|
.map_err(|e| SignError::Spawn(e.to_string()))?;
|
|
|
|
let mut stdin = child.stdin.take().ok_or(SignError::Stdin)?;
|
|
|
|
stdin
|
|
.write_all(commit)
|
|
.map_err(|e| SignError::WriteBuffer(e.to_string()))?;
|
|
drop(stdin); // close stdin to not block indefinitely
|
|
|
|
let output = child
|
|
.wait_with_output()
|
|
.map_err(|e| SignError::Output(e.to_string()))?;
|
|
|
|
if !output.status.success() {
|
|
return Err(SignError::Shellout(format!(
|
|
"failed to sign data, program '{}' exited non-zero: {}",
|
|
&self.program,
|
|
std::str::from_utf8(&output.stderr)
|
|
.unwrap_or("[error could not be read from stderr]")
|
|
)));
|
|
}
|
|
|
|
let stderr = std::str::from_utf8(&output.stderr)
|
|
.map_err(|e| SignError::Shellout(e.to_string()))?;
|
|
|
|
if !stderr.contains("\n[GNUPG:] SIG_CREATED ") {
|
|
return Err(SignError::Shellout(
|
|
format!("failed to sign data, program '{}' failed, SIG_CREATED not seen in stderr", &self.program),
|
|
));
|
|
}
|
|
|
|
let signed_commit = std::str::from_utf8(&output.stdout)
|
|
.map_err(|e| SignError::Shellout(e.to_string()))?;
|
|
|
|
Ok((signed_commit.to_string(), Some("gpgsig".to_string())))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn program(&self) -> &String {
|
|
&self.program
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn signing_key(&self) -> &String {
|
|
&self.signing_key
|
|
}
|
|
}
|
|
|
|
/// Sign commit data using `SSHDiskKeySign`
|
|
pub struct SSHSign {
|
|
#[cfg(test)]
|
|
program: String,
|
|
#[cfg(test)]
|
|
key_path: String,
|
|
secret_key: PrivateKey,
|
|
}
|
|
|
|
impl SSHSign {
|
|
/// Create new [`SSHDiskKeySign`] for sign.
|
|
pub fn new(mut key: PathBuf) -> Result<Self, SignBuilderError> {
|
|
key.set_extension("");
|
|
if key.is_file() {
|
|
#[cfg(test)]
|
|
let key_path = format!("{}", &key.display());
|
|
std::fs::read(key)
|
|
.ok()
|
|
.and_then(|bytes| {
|
|
PrivateKey::from_openssh(bytes).ok()
|
|
})
|
|
.map(|secret_key| Self {
|
|
#[cfg(test)]
|
|
program: "ssh".to_string(),
|
|
#[cfg(test)]
|
|
key_path,
|
|
secret_key,
|
|
})
|
|
.ok_or_else(|| {
|
|
SignBuilderError::SSHSigningKey(String::from(
|
|
"Fail to read the private key for sign.",
|
|
))
|
|
})
|
|
} else {
|
|
Err(SignBuilderError::SSHSigningKey(
|
|
String::from("Currently, we only support a pair of ssh key in disk."),
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Sign for SSHSign {
|
|
fn sign(
|
|
&self,
|
|
commit: &[u8],
|
|
) -> Result<(String, Option<String>), SignError> {
|
|
let sig = self
|
|
.secret_key
|
|
.sign("git", HashAlg::Sha256, commit)
|
|
.map_err(|err| SignError::Spawn(err.to_string()))?
|
|
.to_pem(LineEnding::LF)
|
|
.map_err(|err| SignError::Spawn(err.to_string()))?;
|
|
Ok((sig, None))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn program(&self) -> &String {
|
|
&self.program
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn signing_key(&self) -> &String {
|
|
&self.key_path
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::error::Result;
|
|
use crate::sync::tests::repo_init_empty;
|
|
|
|
#[test]
|
|
fn test_invalid_signing_format() -> Result<()> {
|
|
let (_temp_dir, repo) = repo_init_empty()?;
|
|
|
|
{
|
|
let mut config = repo.config()?;
|
|
config.set_str("gpg.format", "INVALID_SIGNING_FORMAT")?;
|
|
}
|
|
|
|
let sign =
|
|
SignBuilder::from_gitconfig(&repo, &repo.config()?);
|
|
|
|
assert!(sign.is_err());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_program_and_signing_key_defaults() -> Result<()> {
|
|
let (_tmp_dir, repo) = repo_init_empty()?;
|
|
let sign =
|
|
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
|
|
|
|
assert_eq!("gpg", sign.program());
|
|
assert_eq!("name <email>", sign.signing_key());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_gpg_program_configs() -> Result<()> {
|
|
let (_tmp_dir, repo) = repo_init_empty()?;
|
|
|
|
{
|
|
let mut config = repo.config()?;
|
|
config.set_str("gpg.program", "GPG_PROGRAM_TEST")?;
|
|
}
|
|
|
|
let sign =
|
|
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
|
|
|
|
// we get gpg.program, because gpg.openpgp.program is not set
|
|
assert_eq!("GPG_PROGRAM_TEST", sign.program());
|
|
|
|
{
|
|
let mut config = repo.config()?;
|
|
config.set_str(
|
|
"gpg.openpgp.program",
|
|
"GPG_OPENPGP_PROGRAM_TEST",
|
|
)?;
|
|
}
|
|
|
|
let sign =
|
|
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
|
|
|
|
// since gpg.openpgp.program is now set as well, it is more specific than
|
|
// gpg.program and therefore takes precedence
|
|
assert_eq!("GPG_OPENPGP_PROGRAM_TEST", sign.program());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_user_signingkey() -> Result<()> {
|
|
let (_tmp_dir, repo) = repo_init_empty()?;
|
|
|
|
{
|
|
let mut config = repo.config()?;
|
|
config.set_str("user.signingKey", "FFAA")?;
|
|
}
|
|
|
|
let sign =
|
|
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
|
|
|
|
assert_eq!("FFAA", sign.signing_key());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_ssh_program_configs() -> Result<()> {
|
|
let (_tmp_dir, repo) = repo_init_empty()?;
|
|
|
|
{
|
|
let mut config = repo.config()?;
|
|
config.set_str("gpg.program", "ssh")?;
|
|
config.set_str("user.signingKey", "/tmp/key.pub")?;
|
|
}
|
|
|
|
let sign =
|
|
SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
|
|
|
|
assert_eq!("ssh", sign.program());
|
|
assert_eq!("/tmp/key.pub", sign.signing_key());
|
|
|
|
Ok(())
|
|
}
|
|
}
|