1use std::path::PathBuf;
4
5use ssh_key::{HashAlg, LineEnding, PrivateKey};
6
7#[derive(thiserror::Error, Debug)]
9pub enum SignBuilderError {
10 #[error("Failed to derive a commit signing method from git configuration 'gpg.format': {0}")]
12 InvalidFormat(String),
13
14 #[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
16 GPGSigningKey(String),
17
18 #[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
20 SSHSigningKey(String),
21
22 #[error("Failed to build signing signature: {0}")]
25 Signature(String),
26
27 #[error("Select signing method '{0}' has not been implemented")]
30 MethodNotImplemented(String),
31}
32
33#[derive(thiserror::Error, Debug)]
35pub enum SignError {
36 #[error("Failed to spawn signing process: {0}")]
38 Spawn(String),
39
40 #[error("Failed to acquire standard input handler")]
43 Stdin,
44
45 #[error("Failed to write buffer to standard input of signing process: {0}")]
48 WriteBuffer(String),
49
50 #[error("Failed to get output of signing process call: {0}")]
52 Output(String),
53
54 #[error("Failed to execute signing process: {0}")]
56 Shellout(String),
57}
58
59pub trait Sign {
61 fn sign(&self, commit: &[u8]) -> Result<(String, Option<String>), SignError>;
76
77 #[cfg(test)]
78 fn program(&self) -> &String;
79
80 #[cfg(test)]
81 fn signing_key(&self) -> &String;
82}
83
84pub struct SignBuilder;
87
88impl SignBuilder {
89 pub fn from_gitconfig(
110 repo: &git2::Repository,
111 config: &git2::Config,
112 ) -> Result<Box<dyn Sign>, SignBuilderError> {
113 let format = config
114 .get_string("gpg.format")
115 .unwrap_or_else(|_| "openpgp".to_string());
116
117 match format.as_str() {
120 "openpgp" => {
121 let program = config
128 .get_string("gpg.openpgp.program")
129 .or_else(|_| config.get_string("gpg.program"))
130 .unwrap_or_else(|_| "gpg".to_string());
131
132 let signing_key = config
137 .get_string("user.signingKey")
138 .or_else(|_| -> Result<String, SignBuilderError> {
139 Ok(crate::sync::commit::signature_allow_undefined_name(repo)
140 .map_err(|err| SignBuilderError::Signature(err.to_string()))?
141 .to_string())
142 })
143 .map_err(|err| SignBuilderError::GPGSigningKey(err.to_string()))?;
144
145 Ok(Box::new(GPGSign {
146 program,
147 signing_key,
148 }))
149 }
150 "x509" => Err(SignBuilderError::MethodNotImplemented(String::from("x509"))),
151 "ssh" => {
152 let ssh_signer = config
153 .get_string("user.signingKey")
154 .ok()
155 .and_then(|key_path| {
156 key_path.strip_prefix('~').map_or_else(
157 || Some(PathBuf::from(&key_path)),
158 |ssh_key_path| {
159 dirs::home_dir().map(|home| {
160 home.join(
161 ssh_key_path.strip_prefix('/').unwrap_or(ssh_key_path),
162 )
163 })
164 },
165 )
166 })
167 .ok_or_else(|| {
168 SignBuilderError::SSHSigningKey(String::from("ssh key setting absent"))
169 })
170 .and_then(SSHSign::new)?;
171 let signer: Box<dyn Sign> = Box::new(ssh_signer);
172 Ok(signer)
173 }
174 _ => Err(SignBuilderError::InvalidFormat(format)),
175 }
176 }
177}
178
179pub struct GPGSign {
181 program: String,
182 signing_key: String,
183}
184
185impl GPGSign {
186 pub fn new(program: &str, signing_key: &str) -> Self {
188 Self {
189 program: program.to_string(),
190 signing_key: signing_key.to_string(),
191 }
192 }
193}
194
195impl Sign for GPGSign {
196 fn sign(&self, commit: &[u8]) -> Result<(String, Option<String>), SignError> {
197 use std::{
198 io::Write,
199 process::{Command, Stdio},
200 };
201
202 let mut cmd = Command::new(&self.program);
203 cmd.stdin(Stdio::piped())
204 .stdout(Stdio::piped())
205 .stderr(Stdio::piped())
206 .arg("--status-fd=2")
207 .arg("-bsau")
208 .arg(&self.signing_key);
209
210 log::trace!("signing command: {cmd:?}");
211
212 let mut child = cmd.spawn().map_err(|e| SignError::Spawn(e.to_string()))?;
213
214 let mut stdin = child.stdin.take().ok_or(SignError::Stdin)?;
215
216 stdin
217 .write_all(commit)
218 .map_err(|e| SignError::WriteBuffer(e.to_string()))?;
219 drop(stdin); let output = child
222 .wait_with_output()
223 .map_err(|e| SignError::Output(e.to_string()))?;
224
225 if !output.status.success() {
226 return Err(SignError::Shellout(format!(
227 "failed to sign data, program '{}' exited non-zero: {}",
228 &self.program,
229 std::str::from_utf8(&output.stderr)
230 .unwrap_or("[error could not be read from stderr]")
231 )));
232 }
233
234 let stderr =
235 std::str::from_utf8(&output.stderr).map_err(|e| SignError::Shellout(e.to_string()))?;
236
237 if !stderr.contains("\n[GNUPG:] SIG_CREATED ") {
238 return Err(SignError::Shellout(format!(
239 "failed to sign data, program '{}' failed, SIG_CREATED not seen in stderr",
240 &self.program
241 )));
242 }
243
244 let signed_commit =
245 std::str::from_utf8(&output.stdout).map_err(|e| SignError::Shellout(e.to_string()))?;
246
247 Ok((signed_commit.to_string(), Some("gpgsig".to_string())))
248 }
249
250 #[cfg(test)]
251 fn program(&self) -> &String {
252 &self.program
253 }
254
255 #[cfg(test)]
256 fn signing_key(&self) -> &String {
257 &self.signing_key
258 }
259}
260
261pub struct SSHSign {
263 #[cfg(test)]
264 program: String,
265 #[cfg(test)]
266 key_path: String,
267 secret_key: PrivateKey,
268}
269
270impl SSHSign {
271 pub fn new(mut key: PathBuf) -> Result<Self, SignBuilderError> {
273 key.set_extension("");
274 if key.is_file() {
275 #[cfg(test)]
276 let key_path = format!("{}", &key.display());
277 std::fs::read(key)
278 .ok()
279 .and_then(|bytes| PrivateKey::from_openssh(bytes).ok())
280 .map(|secret_key| Self {
281 #[cfg(test)]
282 program: "ssh".to_string(),
283 #[cfg(test)]
284 key_path,
285 secret_key,
286 })
287 .ok_or_else(|| {
288 SignBuilderError::SSHSigningKey(String::from(
289 "Fail to read the private key for sign.",
290 ))
291 })
292 } else {
293 Err(SignBuilderError::SSHSigningKey(String::from(
294 "Currently, we only support a pair of ssh key in disk.",
295 )))
296 }
297 }
298}
299
300impl Sign for SSHSign {
301 fn sign(&self, commit: &[u8]) -> Result<(String, Option<String>), SignError> {
302 let sig = self
303 .secret_key
304 .sign("git", HashAlg::Sha256, commit)
305 .map_err(|err| SignError::Spawn(err.to_string()))?
306 .to_pem(LineEnding::LF)
307 .map_err(|err| SignError::Spawn(err.to_string()))?;
308 Ok((sig, None))
309 }
310
311 #[cfg(test)]
312 fn program(&self) -> &String {
313 &self.program
314 }
315
316 #[cfg(test)]
317 fn signing_key(&self) -> &String {
318 &self.key_path
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use crate::{error::Result, sync::tests::repo_init_empty};
326
327 #[test]
328 fn test_invalid_signing_format() -> Result<()> {
329 let (_temp_dir, repo) = repo_init_empty()?;
330
331 {
332 let mut config = repo.config()?;
333 config.set_str("gpg.format", "INVALID_SIGNING_FORMAT")?;
334 }
335
336 let sign = SignBuilder::from_gitconfig(&repo, &repo.config()?);
337
338 assert!(sign.is_err());
339
340 Ok(())
341 }
342
343 #[test]
344 fn test_program_and_signing_key_defaults() -> Result<()> {
345 let (_tmp_dir, repo) = repo_init_empty()?;
346 let sign = SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
347
348 assert_eq!("gpg", sign.program());
349 assert_eq!("name <email>", sign.signing_key());
350
351 Ok(())
352 }
353
354 #[test]
355 fn test_gpg_program_configs() -> Result<()> {
356 let (_tmp_dir, repo) = repo_init_empty()?;
357
358 {
359 let mut config = repo.config()?;
360 config.set_str("gpg.program", "GPG_PROGRAM_TEST")?;
361 }
362
363 let sign = SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
364
365 assert_eq!("GPG_PROGRAM_TEST", sign.program());
367
368 {
369 let mut config = repo.config()?;
370 config.set_str("gpg.openpgp.program", "GPG_OPENPGP_PROGRAM_TEST")?;
371 }
372
373 let sign = SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
374
375 assert_eq!("GPG_OPENPGP_PROGRAM_TEST", sign.program());
378
379 Ok(())
380 }
381
382 #[test]
383 fn test_user_signingkey() -> Result<()> {
384 let (_tmp_dir, repo) = repo_init_empty()?;
385
386 {
387 let mut config = repo.config()?;
388 config.set_str("user.signingKey", "FFAA")?;
389 }
390
391 let sign = SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
392
393 assert_eq!("FFAA", sign.signing_key());
394 Ok(())
395 }
396
397 #[test]
398 fn test_ssh_program_configs() -> Result<()> {
399 let (_tmp_dir, repo) = repo_init_empty()?;
400
401 {
402 let mut config = repo.config()?;
403 config.set_str("gpg.program", "ssh")?;
404 config.set_str("user.signingKey", "/tmp/key.pub")?;
405 }
406
407 let sign = SignBuilder::from_gitconfig(&repo, &repo.config()?)?;
408
409 assert_eq!("ssh", sign.program());
410 assert_eq!("/tmp/key.pub", sign.signing_key());
411
412 Ok(())
413 }
414}