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