diff --git a/.clippy.toml b/.clippy.toml index 1c443be..efa3e89 100644 --- a/.clippy.toml +++ b/.clippy.toml @@ -1 +1 @@ -msrv = "1.61.0" # MSRV +msrv = "1.65.0" # MSRV diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c97801..f48d2aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: - name: No-default features run: cargo test --workspace --no-default-features msrv: - name: "Check MSRV: 1.61.0" + name: "Check MSRV: 1.65.0" runs-on: ubuntu-latest steps: - name: Checkout repository @@ -68,7 +68,7 @@ jobs: - name: Install Rust uses: actions-rs/toolchain@v1 with: - toolchain: 1.61.0 # MSRV + toolchain: 1.65.0 # MSRV profile: minimal override: true - uses: Swatinem/rust-cache@v2 @@ -122,7 +122,7 @@ jobs: - name: Install Rust uses: actions-rs/toolchain@v1 with: - toolchain: 1.61.0 # MSRV + toolchain: 1.65.0 # MSRV profile: minimal override: true components: clippy diff --git a/.github/workflows/rust-next.yml b/.github/workflows/rust-next.yml index 3eb63ad..3e333f9 100644 --- a/.github/workflows/rust-next.yml +++ b/.github/workflows/rust-next.yml @@ -71,9 +71,9 @@ jobs: strategy: matrix: rust: - - 1.61.0 # MSRV + - 1.65.0 # MSRV - stable - continue-on-error: ${{ matrix.rust != '1.61.0' }} # MSRV + continue-on-error: ${{ matrix.rust != '1.65.0' }} # MSRV runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/Cargo.lock b/Cargo.lock index 4d54427..36a1a31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -65,9 +65,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fca0852af221f458706eb0725c03e4ed6c46af9ac98e6a689d5e634215d594dd" +checksum = "b45ea9b00a7b3f2988e9a65ad3917e62123c38dba709b666506207be96d1790b" dependencies = [ "memchr", "once_cell", @@ -344,7 +344,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8316938721002656fefc2e819dfd44f78d56bd8d1662064026660a44735c5813" dependencies = [ - "bstr 1.0.1", + "bstr 1.1.0", "derive_more", "eyre", "git2", @@ -373,6 +373,7 @@ name = "git2-ext" version = "0.2.0" dependencies = [ "assert_fs", + "bstr 1.1.0", "criterion", "eyre", "git-fixture", @@ -380,7 +381,9 @@ dependencies = [ "itertools", "log", "regex", + "shlex", "snapbox", + "tempfile", "which", ] @@ -919,6 +922,12 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "shlex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b2853a4d09f215c24cc5489c992ce46052d359b5109343cbafbf26bc62f8a3" + [[package]] name = "similar" version = "2.2.1" diff --git a/Cargo.toml b/Cargo.toml index e1cd600..bca9a11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ readme = "README.md" categories = ["command-line-interface"] keywords = ["git"] edition = "2021" -rust-version = "1.61.0" # MSRV +rust-version = "1.65.0" # MSRV include = [ "build.rs", "src/**/*", @@ -35,6 +35,9 @@ git2 = { version = "0.15", default-features = false } log = "0.4" itertools = "0.10" which = "4" +bstr = { version = "1.1.0", default-features = false } +tempfile = "3.3.0" +shlex = "1.1.0" [dev-dependencies] git-fixture = { version = "0.3", features = ["yaml"] } diff --git a/src/ops.rs b/src/ops.rs index 5f76398..9e5d0cf 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -4,6 +4,7 @@ //! They serve as both examples on how to use `git2` but also should be usable in some limited //! subset of cases. +use bstr::ByteSlice; use itertools::Itertools; /// Lookup the commit ID for `HEAD` @@ -255,7 +256,7 @@ pub fn commit( if let Some(sign) = sign { let content = repo.commit_create_buffer(author, committer, message, tree, parents)?; let content = std::str::from_utf8(&content).unwrap(); - let signed = sign.sign(content); + let signed = sign.sign(content)?; repo.commit_signed(content, &signed, None) } else { repo.commit(None, author, committer, message, tree, parents) @@ -266,5 +267,389 @@ pub fn commit( /// /// See for an example of what to do. pub trait Sign { - fn sign(&self, buffer: &str) -> String; + fn sign(&self, buffer: &str) -> Result; +} + +pub struct UserSign(UserSignInner); + +enum UserSignInner { + Gpg(GpgSign), + Ssh(SshSign), +} + +impl UserSign { + pub fn from_config( + repo: &git2::Repository, + config: &git2::Config, + ) -> Result { + let format = config + .get_string("gpg.format") + .unwrap_or_else(|_| "openpgp".to_owned()); + match format.as_str() { + "openpgp" => { + let program = config + .get_string("gpg.openpgp.program") + .or_else(|_| config.get_string("gpg.program")) + .unwrap_or_else(|_| "gpg".to_owned()); + + let signing_key = config.get_string("user.signingkey").or_else( + |_| -> Result<_, git2::Error> { + let sig = repo.signature()?; + Ok(String::from_utf8_lossy(sig.name_bytes()).into_owned()) + }, + )?; + + Ok(UserSign(UserSignInner::Gpg(GpgSign::new( + program, + signing_key, + )))) + } + "x509" => { + let program = config + .get_string("gpg.x509.program") + .unwrap_or_else(|_| "gpgsm".to_owned()); + + let signing_key = config.get_string("user.signingkey").or_else( + |_| -> Result<_, git2::Error> { + let sig = repo.signature()?; + Ok(String::from_utf8_lossy(sig.name_bytes()).into_owned()) + }, + )?; + + Ok(UserSign(UserSignInner::Gpg(GpgSign::new( + program, + signing_key, + )))) + } + "ssh" => { + let program = config + .get_string("gpg.ssh.program") + .unwrap_or_else(|_| "ssh-keygen".to_owned()); + + let signing_key = config + .get_string("user.signingkey") + .map(Ok) + .unwrap_or_else(|_| -> Result<_, git2::Error> { + get_default_ssh_signing_key(config)?.map(Ok).unwrap_or_else( + || -> Result<_, git2::Error> { + let sig = repo.signature()?; + Ok(String::from_utf8_lossy(sig.name_bytes()).into_owned()) + }, + ) + })?; + + Ok(UserSign(UserSignInner::Ssh(SshSign::new( + program, + signing_key, + )))) + } + _ => Err(git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Config, + format!("invalid valid for gpg.format: {}", format), + )), + } + } +} + +impl Sign for UserSign { + fn sign(&self, buffer: &str) -> Result { + match &self.0 { + UserSignInner::Gpg(s) => s.sign(buffer), + UserSignInner::Ssh(s) => s.sign(buffer), + } + } +} + +pub struct GpgSign { + program: String, + signing_key: String, +} + +impl GpgSign { + pub fn new(program: String, signing_key: String) -> Self { + Self { + program, + signing_key, + } + } +} + +impl Sign for GpgSign { + fn sign(&self, buffer: &str) -> Result { + let output = pipe_command( + std::process::Command::new(&self.program) + .arg("--status-fd=2") + .arg("-bsau") + .arg(&self.signing_key), + Some(buffer), + ) + .map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("{} failed to sign the data: {}", self.program, e), + ) + })?; + if !output.status.success() { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("{} failed to sign the data", self.program), + )); + } + if output.stderr.find(b"\n[GNUPG:] SIG_CREATED ").is_none() { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("{} failed to sign the data", self.program), + )); + } + + let sig = std::str::from_utf8(&output.stdout).map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("{} failed to sign the data: {}", self.program, e), + ) + })?; + + // Strip CR from the line endings, in case we are on Windows. + let normalized = remove_cr_after(sig); + + Ok(normalized) + } +} + +pub struct SshSign { + program: String, + signing_key: String, +} + +impl SshSign { + pub fn new(program: String, signing_key: String) -> Self { + Self { + program, + signing_key, + } + } +} + +impl Sign for SshSign { + fn sign(&self, buffer: &str) -> Result { + let mut literal_key_file = None; + let ssh_signing_key_file = if let Some(literal_key) = literal_key(&self.signing_key) { + let temp = tempfile::NamedTempFile::new().map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("failed writing ssh signing key: {}", e), + ) + })?; + + std::fs::write(temp.path(), literal_key).map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("failed writing ssh signing key: {}", e), + ) + })?; + let path = temp.path().to_owned(); + literal_key_file = Some(temp); + path + } else { + fn expanduser(path: &str) -> std::path::PathBuf { + // HACK: Need a cross-platform solution + std::path::PathBuf::from(path) + } + + // We assume a file + expanduser(&self.signing_key) + }; + + let buffer_file = tempfile::NamedTempFile::new().map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("failed writing buffer: {}", e), + ) + })?; + std::fs::write(buffer_file.path(), buffer).map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("failed writing buffer: {}", e), + ) + })?; + + let output = pipe_command( + std::process::Command::new(&self.program) + .arg("-Y") + .arg("sign") + .arg("-n") + .arg("git") + .arg("-f") + .arg(&ssh_signing_key_file) + .arg(buffer_file.path()), + Some(buffer), + ) + .map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("{} failed to sign the data: {}", self.program, e), + ) + })?; + if !output.status.success() { + if output.stderr.find("usage:").is_some() { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + "ssh-keygen -Y sign is needed for ssh signing (available in openssh version 8.2p1+)" + )); + } else { + return Err(git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!( + "{} failed to sign the data: {}", + self.program, + String::from_utf8_lossy(&output.stderr) + ), + )); + } + } + + let mut ssh_signature_filename = buffer_file.path().as_os_str().to_owned(); + ssh_signature_filename.push(".sig"); + let ssh_signature_filename = std::path::PathBuf::from(ssh_signature_filename); + let sig = std::fs::read_to_string(&ssh_signature_filename).map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!( + "failed reading ssh signing data buffer from {}: {}", + ssh_signature_filename.display(), + e + ), + ) + })?; + // Strip CR from the line endings, in case we are on Windows. + let normalized = remove_cr_after(&sig); + + buffer_file.close().map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("failed writing buffer: {}", e), + ) + })?; + if let Some(literal_key_file) = literal_key_file { + literal_key_file.close().map_err(|e| { + git2::Error::new( + git2::ErrorCode::GenericError, + git2::ErrorClass::Os, + format!("failed writing ssh signing key: {}", e), + ) + })?; + } + + Ok(normalized) + } +} + +fn pipe_command( + cmd: &mut std::process::Command, + stdin: Option<&str>, +) -> Result { + use std::io::Write; + + let mut child = cmd + .stdin(if stdin.is_some() { + std::process::Stdio::piped() + } else { + std::process::Stdio::null() + }) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + if let Some(stdin) = stdin { + let mut stdin_sync = child.stdin.take().expect("stdin is piped"); + write!(stdin_sync, "{}", stdin)?; + } + child.wait_with_output() +} + +fn remove_cr_after(sig: &str) -> String { + let mut normalized = String::new(); + for line in sig.lines() { + normalized.push_str(line); + normalized.push('\n'); + } + normalized +} + +fn literal_key(signing_key: &str) -> Option<&str> { + if let Some(literal) = signing_key.strip_prefix("key::") { + Some(literal) + } else if signing_key.starts_with("ssh-") { + Some(signing_key) + } else { + None + } +} + +// Returns the first public key from an ssh-agent to use for signing +fn get_default_ssh_signing_key(config: &git2::Config) -> Result, git2::Error> { + let ssh_default_key_command = config + .get_string("gpg.ssh.defaultKeyCommand") + .map_err(|_| { + git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Config, + "either user.signingkey or gpg.ssh.defaultKeyCommand needs to be configured", + ) + })?; + let ssh_default_key_args = shlex::split(&ssh_default_key_command).ok_or_else(|| { + git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Config, + format!( + "malformed gpg.ssh.defaultKeyCommand: {}", + ssh_default_key_command + ), + ) + })?; + if ssh_default_key_args.is_empty() { + return Err(git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Config, + format!( + "malformed gpg.ssh.defaultKeyCommand: {}", + ssh_default_key_command + ), + )); + } + + let Ok(output) = pipe_command( + std::process::Command::new(&ssh_default_key_args[0]) + .args(&ssh_default_key_args[1..]), + None, + ) else { + return Ok(None); + }; + + let Ok(keys) = std::str::from_utf8(&output.stdout) else { + return Ok(None); + }; + let Some((default_key, _)) = keys.split_once('\n') else { + return Ok(None); + }; + // We only use `is_literal_ssh_key` here to check validity + // The prefix will be stripped when the key is used + if literal_key(default_key).is_none() { + return Ok(None); + } + + Ok(Some(default_key.to_owned())) } diff --git a/src/utils.rs b/src/utils.rs index 6e07fe4..5bf9312 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -22,5 +22,5 @@ fn find_git_bash() -> Option { let git_path = which::which("git.exe").ok()?; let git_dir = git_path.parent()?.parent()?; let git_bash = git_dir.join("bin").join("bash.exe"); - git_bash.is_file().then(|| git_bash) + git_bash.is_file().then_some(git_bash) }