diff options
Diffstat (limited to 'asyncgit/src/sync/sign.rs')
-rw-r--r-- | asyncgit/src/sync/sign.rs | 440 |
1 files changed, 440 insertions, 0 deletions
diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs new file mode 100644 index 00000000..53effe24 --- /dev/null +++ b/asyncgit/src/sync/sign.rs @@ -0,0 +1,440 @@ +//! 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>; + + #[cfg(test)] + fn program(&self) -> &String; + + #[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(()) + } +} |