diff options
Diffstat (limited to 'asyncgit')
-rw-r--r-- | asyncgit/Cargo.toml | 6 | ||||
-rw-r--r-- | asyncgit/src/error.rs | 20 | ||||
-rw-r--r-- | asyncgit/src/sync/branch/mod.rs | 14 | ||||
-rw-r--r-- | asyncgit/src/sync/commit.rs | 129 | ||||
-rw-r--r-- | asyncgit/src/sync/commit_details.rs | 2 | ||||
-rw-r--r-- | asyncgit/src/sync/cred.rs | 51 | ||||
-rw-r--r-- | asyncgit/src/sync/mod.rs | 7 | ||||
-rw-r--r-- | asyncgit/src/sync/remotes/mod.rs | 140 | ||||
-rw-r--r-- | asyncgit/src/sync/remotes/push.rs | 4 | ||||
-rw-r--r-- | asyncgit/src/sync/reword.rs | 28 | ||||
-rw-r--r-- | asyncgit/src/sync/sign.rs | 440 | ||||
-rw-r--r-- | asyncgit/src/sync/tags.rs | 2 |
12 files changed, 813 insertions, 30 deletions
diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index deb9beae..40a83fff 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "asyncgit" -version = "0.25.0" +version = "0.26.0" authors = ["extrawurst <mail@rusticorn.com>"] edition = "2021" description = "allow using git2 in a asynchronous context" @@ -14,6 +14,7 @@ keywords = ["git"] [dependencies] bitflags = "2" crossbeam-channel = "0.5" +dirs = "5.0" easy-cast = "0.5" fuzzy-matcher = "0.3" git2 = "0.18" @@ -23,10 +24,11 @@ log = "0.4" # git2 = { git="https://github.com/extrawurst/git2-rs.git", rev="fc13dcc", features = ["vendored-openssl"]} # pinning to vendored openssl, using the git2 feature this gets lost with new resolver openssl-sys = { version = '0.9', features = ["vendored"], optional = true } -rayon = "1.8" +rayon = "1.10" rayon-core = "1.12" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } +ssh-key = { version = "0.6.6", features = ["crypto", "encryption"] } thiserror = "1.0" unicode-truncate = "0.2" url = "2.5" 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/branch/mod.rs b/asyncgit/src/sync/branch/mod.rs index 4dbe89aa..02645cf5 100644 --- a/asyncgit/src/sync/branch/mod.rs +++ b/asyncgit/src/sync/branch/mod.rs @@ -5,13 +5,13 @@ pub mod merge_ff; pub mod merge_rebase; pub mod rename; -use super::{ - remotes::get_default_remote_in_repo, utils::bytes2string, - RepoPath, -}; +use super::{utils::bytes2string, RepoPath}; use crate::{ error::{Error, Result}, - sync::{repository::repo, utils::get_head_repo, CommitId}, + sync::{ + remotes::get_default_remote_for_push_in_repo, + repository::repo, utils::get_head_repo, CommitId, + }, }; use git2::{Branch, BranchType, Repository}; use scopetime::scope_time; @@ -209,7 +209,7 @@ pub struct BranchCompare { } /// -pub(crate) fn branch_set_upstream( +pub(crate) fn branch_set_upstream_after_push( repo: &Repository, branch_name: &str, ) -> Result<()> { @@ -219,7 +219,7 @@ pub(crate) fn branch_set_upstream( repo.find_branch(branch_name, BranchType::Local)?; if branch.upstream().is_err() { - let remote = get_default_remote_in_repo(repo)?; + let remote = get_default_remote_for_push_in_repo(repo)?; let upstream_name = format!("{remote}/{branch_name}"); branch.set_upstream(Some(upstream_name.as_str()))?; } diff --git a/asyncgit/src/sync/commit.rs b/asyncgit/src/sync/commit.rs index b2b7a474..2b1d78fe 100644 --- a/asyncgit/src/sync/commit.rs +++ b/asyncgit/src/sync/commit.rs @@ -1,9 +1,13 @@ +//! 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::{ErrorCode, ObjectType, Repository, Signature}; +use git2::{ + message_prettify, ErrorCode, ObjectType, Repository, Signature, +}; use scopetime::scope_time; /// @@ -15,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, @@ -65,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()?; @@ -79,8 +98,50 @@ 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) + { + 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 signer = SignBuilder::from_gitconfig(&repo, &config)?; + let (signature, signature_field) = signer.sign(&buffer)?; + let commit_id = repo.commit_signed( + commit, + &signature, + signature_field.as_deref(), + )?; + + // 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 default 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, @@ -88,7 +149,9 @@ pub fn commit(repo_path: &RepoPath, msg: &str) -> Result<CommitId> { &tree, parents.as_slice(), )? - .into()) + }; + + Ok(commit_id.into()) } /// Tag a commit. @@ -119,6 +182,21 @@ pub fn tag_commit( Ok(c) } +/// Loads the comment prefix from config & uses it to prettify commit messages +pub fn commit_message_prettify( + repo_path: &RepoPath, + message: String, +) -> Result<String> { + let comment_char = repo(repo_path)? + .config()? + .get_string("core.commentChar") + .ok() + .and_then(|char_string| char_string.chars().next()) + .unwrap_or('#') as u8; + + Ok(message_prettify(message, Some(comment_char))?) +} + #[cfg(test)] mod tests { use crate::error::Result; @@ -131,7 +209,7 @@ mod tests { utils::get_head, LogWalker, }; - use commit::{amend, tag_commit}; + use commit::{amend, commit_message_prettify, tag_commit}; use git2::Repository; use std::{fs::File, io::Write, path::Path}; @@ -383,4 +461,41 @@ mod tests { Ok(()) } + + #[test] + fn test_empty_comment_char() -> Result<()> { + let (_td, repo) = repo_init_empty().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + let message = commit_message_prettify( + repo_path, + "#This is a test message\nTest".to_owned(), + )?; + + assert_eq!(message, "Test\n"); + Ok(()) + } + + #[test] + fn test_with_comment_char() -> Result<()> { + let (_td, repo) = repo_init_empty().unwrap(); + + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + repo.config()?.set_str("core.commentChar", ";")?; + + let message = commit_message_prettify( + repo_path, + ";This is a test message\nTest".to_owned(), + )?; + + assert_eq!(message, "Test\n"); + + Ok(()) + } } diff --git a/asyncgit/src/sync/commit_details.rs b/asyncgit/src/sync/commit_details.rs index 8de0894b..cc60a3c6 100644 --- a/asyncgit/src/sync/commit_details.rs +++ b/asyncgit/src/sync/commit_details.rs @@ -82,8 +82,6 @@ pub struct CommitDetails { impl CommitDetails { /// - #[allow(clippy::missing_const_for_fn)] - // clippy doesn't realise indexing a String is not const pub fn short_hash(&self) -> &str { &self.hash[0..7] } diff --git a/asyncgit/src/sync/cred.rs b/asyncgit/src/sync/cred.rs index 3f908a63..54eb2cce 100644 --- a/asyncgit/src/sync/cred.rs +++ b/asyncgit/src/sync/cred.rs @@ -1,7 +1,12 @@ //! credentials git helper use super::{ - remotes::get_default_remote_in_repo, repository::repo, RepoPath, + remotes::{ + get_default_remote_for_push_in_repo, + get_default_remote_in_repo, + }, + repository::repo, + RepoPath, }; use crate::error::{Error, Result}; use git2::CredentialHelper; @@ -43,6 +48,22 @@ pub fn need_username_password(repo_path: &RepoPath) -> Result<bool> { Ok(is_http) } +/// know if username and password are needed for this url +pub fn need_username_password_for_push( + repo_path: &RepoPath, +) -> Result<bool> { + let repo = repo(repo_path)?; + let remote = repo + .find_remote(&get_default_remote_for_push_in_repo(&repo)?)?; + let url = remote + .pushurl() + .or_else(|| remote.url()) + .ok_or(Error::UnknownRemote)? + .to_owned(); + let is_http = url.starts_with("http"); + Ok(is_http) +} + /// extract username and password pub fn extract_username_password( repo_path: &RepoPath, @@ -71,6 +92,34 @@ pub fn extract_username_password( }) } +/// extract username and password +pub fn extract_username_password_for_push( + repo_path: &RepoPath, +) -> Result<BasicAuthCredential> { + let repo = repo(repo_path)?; + let url = repo + .find_remote(&get_default_remote_for_push_in_repo(&repo)?)? + .url() + .ok_or(Error::UnknownRemote)? + .to_owned(); + let mut helper = CredentialHelper::new(&url); + + //TODO: look at Cred::credential_helper, + //if the username is in the url we need to set it here, + //I dont think `config` will pick it up + + if let Ok(config) = repo.config() { + helper.config(&config); + } + + Ok(match helper.execute() { + Some((username, password)) => { + BasicAuthCredential::new(Some(username), Some(password)) + } + None => extract_cred_from_url(&url), + }) +} + /// extract credentials from url pub fn extract_cred_from_url(url: &str) -> BasicAuthCredential { url::Url::parse(url).map_or_else( diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index c188f28c..f5334ba4 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -5,7 +5,7 @@ pub mod blame; pub mod branch; -mod commit; +pub mod commit; mod commit_details; pub mod commit_files; mod commit_filter; @@ -25,6 +25,7 @@ pub mod remotes; mod repository; mod reset; mod reword; +pub mod sign; mod staging; mod stash; mod state; @@ -78,8 +79,8 @@ pub use merge::{ }; pub use rebase::rebase_branch; pub use remotes::{ - get_default_remote, get_remotes, push::AsyncProgress, - tags::PushTagsProgress, + get_default_remote, get_default_remote_for_push, get_remotes, + push::AsyncProgress, tags::PushTagsProgress, }; pub(crate) use repository::repo; pub use repository::{RepoPath, RepoPathRef}; diff --git a/asyncgit/src/sync/remotes/mod.rs b/asyncgit/src/sync/remotes/mod.rs index 3c68dcec..a271efc0 100644 --- a/asyncgit/src/sync/remotes/mod.rs +++ b/asyncgit/src/sync/remotes/mod.rs @@ -51,6 +51,81 @@ pub fn get_default_remote(repo_path: &RepoPath) -> Result<String> { get_default_remote_in_repo(&repo) } +/// Gets the current branch the user is on. +/// Returns none if they are not on a branch +/// and Err if there was a problem finding the branch +fn get_current_branch( + repo: &Repository, +) -> Result<Option<git2::Branch>> { + for b in repo.branches(None)? { + let branch = b?.0; + if branch.is_head() { + return Ok(Some(branch)); + } + } + Ok(None) +} + +/// Tries to find the default repo to push to based on configuration. +/// +/// > remote.pushDefault +/// > +/// > The remote to push to by default. Overrides `branch.<name>.remote` for all branches, and is +/// > overridden by `branch.<name>.pushRemote` for specific branches. +/// +/// > branch.<name>.remote +/// > +/// > When on branch `<name>`, it tells `git fetch` and `git push` which remote to fetch from or +/// > push to. The remote to push to may be overridden with `remote.pushDefault` (for all +/// > branches). The remote to push to, for the current branch, may be further overridden by +/// > `branch.<name>.pushRemote`. If no remote is configured, or if you are not on any branch and +/// > there is more than one remote defined in the repository, it defaults to `origin` for fetching +/// > and `remote.pushDefault` for pushing. +/// +/// [git-config-remote-push-default]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-remotepushDefault +/// [git-config-branch-name-remote]: https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote +/// +/// Falls back to `get_default_remote_in_repo`. +pub fn get_default_remote_for_push( + repo_path: &RepoPath, +) -> Result<String> { + let repo = repo(repo_path)?; + get_default_remote_for_push_in_repo(&repo) +} + +pub(crate) fn get_default_remote_for_push_in_repo( + repo: &Repository, +) -> Result<String> { + scope_time!("get_default_remote_for_push_in_repo"); + + let config = repo.config()?; + + let branch = get_current_branch(repo)?; + + if let Some(branch) = branch { + let remote_name = bytes2string(branch.name_bytes()?)?; + + let entry_name = + format!("branch.{}.pushRemote", &remote_name); + + if let Ok(entry) = config.get_entry(&entry_name) { + return bytes2string(entry.value_bytes()); + } + + if let Ok(entry) = config.get_entry("remote.pushDefault") { + return bytes2string(entry.value_bytes()); + } + + let entry_name = format!("branch.{}.remote", &remote_name); + + if let Ok(entry) = config.get_entry(&entry_name) { + return bytes2string(entry.value_bytes()); + } + } + + get_default_remote_in_repo(repo) +} + /// see `get_default_remote` pub(crate) fn get_default_remote_in_repo( repo: &Repository, @@ -299,9 +374,68 @@ mod tests { ] ); - let res = + let default_remote = get_default_remote_in_repo(&repo(repo_path).unwrap()); - assert_eq!(res.is_err(), true); - assert!(matches!(res, Err(Error::NoDefaultRemoteFound))); + + assert!(matches!( + default_remote, + Err(Error::NoDefaultRemoteFound) + )); + } + + #[test] + fn test_default_remote_for_push() { + let (remote_dir, _remote) = repo_init().unwrap(); + let remote_path = remote_dir.path().to_str().unwrap(); + let (repo_dir, repo) = repo_clone(remote_path).unwrap(); + let repo_path: &RepoPath = &repo_dir + .into_path() + .as_os_str() + .to_str() + .unwrap() + .into(); + + debug_cmd_print( + repo_path, + "git remote rename origin alternate", + ); + + debug_cmd_print( + repo_path, + &format!("git remote add someremote {remote_path}")[..], + ); + + let mut config = repo.config().unwrap(); + + config + .set_str("branch.master.remote", "branchremote") + .unwrap(); + + let default_push_remote = + get_default_remote_for_push_in_repo(&repo); + + assert!( + matches!(default_push_remote, Ok(remote_name) if remote_name == "branchremote") + ); + + config.set_str("remote.pushDefault", "pushdefault").unwrap(); + + let default_push_remote = + get_default_remote_for_push_in_repo(&repo); + + assert!( + matches!(default_push_remote, Ok(remote_name) if remote_name == "pushdefault") + ); + + config + .set_str("branch.master.pushRemote", "branchpushremote") + .unwrap(); + + let default_push_remote = + get_default_remote_for_push_in_repo(&repo); + + assert!( + matches!(default_push_remote, Ok(remote_name) if remote_name == "branchpushremote") + ); } } diff --git a/asyncgit/src/sync/remotes/push.rs b/asyncgit/src/sync/remotes/push.rs index 37cdd4a9..dab50f79 100644 --- a/asyncgit/src/sync/remotes/push.rs +++ b/asyncgit/src/sync/remotes/push.rs @@ -2,7 +2,7 @@ use crate::{ error::{Error, Result}, progress::ProgressPercent, sync::{ - branch::branch_set_upstream, + branch::branch_set_upstream_after_push, cred::BasicAuthCredential, remotes::{proxy_auto, Callbacks}, repository::repo, @@ -176,7 +176,7 @@ pub fn push_raw( } if !delete { - branch_set_upstream(&repo, branch)?; + branch_set_upstream_after_push(&repo, branch)?; } Ok(()) 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..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(&am |