diff options
author | Hendrik Maus <hendrikmaus@users.noreply.github.com> | 2024-03-24 21:08:28 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-03-24 13:08:28 -0700 |
commit | 5b3e2c9ae3913855f5dbe463c5ae1c04430e7532 (patch) | |
tree | 86e32107ac4d44b8f7269565d66a07f8afc7f9fd | |
parent | 5131aba138e300ba8d7f3836d67507801d1902d8 (diff) |
Support git commit signing using OpenPGP (#1544)
* Support git commit signing using OpenPGP
* workaround for amending signed commits
* workaround for rewording signed commits
* support signing initial commit
* return both signature and signature_field value from sign
---------
Co-authored-by: Utkarsh Gupta <utkarshgupta137@gmail.com>
-rw-r--r-- | CHANGELOG.md | 3 | ||||
-rw-r--r-- | asyncgit/src/error.rs | 20 | ||||
-rw-r--r-- | asyncgit/src/sync/commit.rs | 72 | ||||
-rw-r--r-- | asyncgit/src/sync/mod.rs | 1 | ||||
-rw-r--r-- | asyncgit/src/sync/reword.rs | 28 | ||||
-rw-r--r-- | asyncgit/src/sync/sign.rs | 325 | ||||
-rw-r--r-- | src/popups/commit.rs | 11 |
7 files changed, 443 insertions, 17 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index e2d50e5b..aae45421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added +* sign commits using openpgp; implement `Sign` trait to implement more methods + ## [0.25.2] - 2024-03-22 ### Fixes diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index 6ede0422..3dba480b 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -84,6 +84,26 @@ pub enum Error { /// #[error("git hook error: {0}")] Hooks(#[from] git2_hooks::HooksError), + + /// + #[error("sign builder error: {0}")] + SignBuilder(#[from] crate::sync::sign::SignBuilderError), + + /// + #[error("sign error: {0}")] + Sign(#[from] crate::sync::sign::SignError), + + /// + #[error("amend error: config commit.gpgsign=true detected.\ngpg signing is not supported for amending non-last commits")] + SignAmendNonLastCommit, + + /// + #[error("reword error: config commit.gpgsign=true detected.\ngpg signing is not supported for rewording non-last commits")] + SignRewordNonLastCommit, + + /// + #[error("reword error: config commit.gpgsign=true detected.\ngpg signing is not supported for rewording commits with staged changes\ntry unstaging or stashing your changes")] + SignRewordLastCommitStaged, } /// diff --git a/asyncgit/src/sync/commit.rs b/asyncgit/src/sync/commit.rs index 2fa4a517..01d8a878 100644 --- a/asyncgit/src/sync/commit.rs +++ b/asyncgit/src/sync/commit.rs @@ -1,7 +1,8 @@ //! Git Api for Commits use super::{CommitId, RepoPath}; +use crate::sync::sign::{SignBuilder, SignError}; use crate::{ - error::Result, + error::{Error, Result}, sync::{repository::repo, utils::get_head_repo}, }; use git2::{ @@ -18,12 +19,27 @@ pub fn amend( scope_time!("amend"); let repo = repo(repo_path)?; + let config = repo.config()?; + let commit = repo.find_commit(id.into())?; let mut index = repo.index()?; let tree_id = index.write_tree()?; let tree = repo.find_tree(tree_id)?; + if config.get_bool("commit.gpgsign").unwrap_or(false) { + // HACK: we undo the last commit and create a new one + use crate::sync::utils::undo_last_commit; + + let head = get_head_repo(&repo)?; + if head == commit.id().into() { + undo_last_commit(repo_path)?; + return self::commit(repo_path, msg); + } + + return Err(Error::SignAmendNonLastCommit); + } + let new_id = commit.amend( Some("HEAD"), None, @@ -68,7 +84,7 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> { scope_time!("commit"); let repo = repo(repo_path)?; - + let config = repo.config()?; let signature = signature_allow_undefined_name(&repo)?; let mut index = repo.index()?; let tree_id = index.write_tree()?; @@ -82,8 +98,52 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> { let parents = parents.iter().collect::<Vec<_>>(); - Ok(repo - .commit( + let commit_id = if config + .get_bool("commit.gpgsign") + .unwrap_or(false) + { + use crate::sync::sign::Sign; + + let buffer = repo.commit_create_buffer( + &signature, + &signature, + msg, + &tree, + parents.as_slice(), + )?; + + let commit = std::str::from_utf8(&buffer).map_err(|_e| { + SignError::Shellout("utf8 conversion error".to_string()) + })?; + + let sign = SignBuilder::from_gitconfig(&repo, &config)?; + let (signature, signature_field) = sign.sign(&buffer)?; + let commit_id = repo.commit_signed( + commit, + &signature, + Some(&signature_field), + )?; + + // manually advance to the new commit ID + // repo.commit does that on its own, repo.commit_signed does not + // if there is no head, read default branch or defaul to "master" + if let Ok(mut head) = repo.head() { + head.set_target(commit_id, msg)?; + } else { + let default_branch_name = config + .get_str("init.defaultBranch") + .unwrap_or("master"); + repo.reference( + &format!("refs/heads/{default_branch_name}"), + commit_id, + true, + msg, + )?; + } + + commit_id + } else { + repo.commit( Some("HEAD"), &signature, &signature, @@ -91,7 +151,9 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> { &tree, parents.as_slice(), )? - .into()) + }; + + Ok(commit_id.into()) } /// Tag a commit. diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index dcc3419e..fd9392c9 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -25,6 +25,7 @@ pub mod remotes; mod repository; mod reset; mod reword; +pub mod sign; mod staging; mod stash; mod state; diff --git a/asyncgit/src/sync/reword.rs b/asyncgit/src/sync/reword.rs index aa1786fc..c20d252b 100644 --- a/asyncgit/src/sync/reword.rs +++ b/asyncgit/src/sync/reword.rs @@ -3,7 +3,7 @@ use git2::{Oid, RebaseOptions, Repository}; use super::{ commit::signature_allow_undefined_name, repo, - utils::{bytes2string, get_head_refname}, + utils::{bytes2string, get_head_refname, get_head_repo}, CommitId, RepoPath, }; use crate::error::{Error, Result}; @@ -15,6 +15,32 @@ pub fn reword( message: &str, ) -> Result<CommitId> { let repo = repo(repo_path)?; + let config = repo.config()?; + + if config.get_bool("commit.gpgsign").unwrap_or(false) { + // HACK: we undo the last commit and create a new one + use crate::sync::utils::undo_last_commit; + + let head = get_head_repo(&repo)?; + if head == commit { + // Check if there are any staged changes + let parent = repo.find_commit(head.into())?; + let tree = parent.tree()?; + if repo + .diff_tree_to_index(Some(&tree), None, None)? + .deltas() + .len() == 0 + { + undo_last_commit(repo_path)?; + return super::commit(repo_path, message); + } + + return Err(Error::SignRewordLastCommitStaged); + } + + return Err(Error::SignRewordNonLastCommit); + } + let cur_branch_ref = get_head_refname(&repo)?; match reword_internal(&repo, commit.get_oid(), message) { diff --git a/asyncgit/src/sync/sign.rs b/asyncgit/src/sync/sign.rs new file mode 100644 index 00000000..d4aa31a6 --- /dev/null +++ b/asyncgit/src/sync/sign.rs @@ -0,0 +1,325 @@ +//! Sign commit data. + +/// 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), + + /// 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, 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<impl 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(GPGSign { + program, + signing_key, + }) + } + "x509" => Err(SignBuilderError::MethodNotImplemented( + String::from("x509"), + )), + "ssh" => Err(SignBuilderError::MethodNotImplemented( + String::from("ssh"), + )), + _ => 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, 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(), "gpgsig".to_string())) + } + + #[cfg(test)] + fn program(&self) -> &String { + &self.program + } + + #[cfg(test)] + fn signing_key(&self) -> &String { + &self.signing_key + } +} + +#[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(()) + } +} diff --git a/src/popups/commit.rs b/src/popups/commit.rs index 36cef4e3..9bd48069 100644 --- a/src/popups/commit.rs +++ b/src/popups/commit.rs @@ -205,17 +205,6 @@ impl CommitPopup { } fn commit(&mut self) -> Result<()> { - let gpgsign = - get_config_string(&self.repo.borrow(), "commit.gpgsign") - .ok() - .flatten() - .and_then(|path| path.parse::<bool>().ok()) - .unwrap_or_default(); - - if gpgsign { - anyhow::bail!("config commit.gpgsign=true detected.\ngpg signing not supported.\ndeactivate in your repo/gitconfig to be able to commit without signing."); - } - let msg = self.input.get_text().to_string(); if matches!( |