gnostr_asyncgit/sync/
sign.rs

1//! Sign commit data.
2
3use std::path::PathBuf;
4
5use ssh_key::{HashAlg, LineEnding, PrivateKey};
6
7/// Error type for [`SignBuilder`], used to create [`Sign`]'s
8#[derive(thiserror::Error, Debug)]
9pub enum SignBuilderError {
10    /// The given format is invalid
11    #[error("Failed to derive a commit signing method from git configuration 'gpg.format': {0}")]
12    InvalidFormat(String),
13
14    /// The GPG signing key could
15    #[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
16    GPGSigningKey(String),
17
18    /// The SSH signing key could
19    #[error("Failed to retrieve 'user.signingkey' from the git configuration: {0}")]
20    SSHSigningKey(String),
21
22    /// No signing signature could be built from the configuration
23    /// data present
24    #[error("Failed to build signing signature: {0}")]
25    Signature(String),
26
27    /// Failure on unimplemented signing methods
28    /// to be removed once all methods have been implemented
29    #[error("Select signing method '{0}' has not been implemented")]
30    MethodNotImplemented(String),
31}
32
33/// Error type for [`Sign`], used to sign data
34#[derive(thiserror::Error, Debug)]
35pub enum SignError {
36    /// Unable to spawn process
37    #[error("Failed to spawn signing process: {0}")]
38    Spawn(String),
39
40    /// Unable to acquire the child process' standard input to write
41    /// the commit data for signing
42    #[error("Failed to acquire standard input handler")]
43    Stdin,
44
45    /// Unable to write commit data to sign to standard input of the
46    /// child process
47    #[error("Failed to write buffer to standard input of signing process: {0}")]
48    WriteBuffer(String),
49
50    /// Unable to retrieve the signed data from the child process
51    #[error("Failed to get output of signing process call: {0}")]
52    Output(String),
53
54    /// Failure of the child process
55    #[error("Failed to execute signing process: {0}")]
56    Shellout(String),
57}
58
59/// Sign commit data using various methods
60pub trait Sign {
61    /// Sign commit with the respective implementation.
62    ///
63    /// Retrieve an implementation using
64    /// [`SignBuilder::from_gitconfig`].
65    ///
66    /// The `commit` buffer can be created using the following steps:
67    /// - create a buffer using
68    ///   [`git2::Repository::commit_create_buffer`]
69    ///
70    /// The function returns a tuple of `signature` and
71    /// `signature_field`. These values can then be passed into
72    /// [`git2::Repository::commit_signed`]. Finally, the repository
73    /// head needs to be advanced to the resulting commit ID
74    /// using [`git2::Reference::set_target`].
75    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
84/// A builder to facilitate the creation of a signing method
85/// ([`Sign`]) by examining the git configuration.
86pub struct SignBuilder;
87
88impl SignBuilder {
89    /// Get a [`Sign`] from the given repository configuration to sign
90    /// commit data
91    ///
92    ///
93    /// ```no_run
94    /// use gnostr_asyncgit::sync::sign::SignBuilder;
95    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
96    ///
97    /// /// Repo in a temporary directory for demonstration
98    /// let dir = std::env::temp_dir();
99    /// let repo = git2::Repository::init(dir)?;
100    ///
101    /// /// Get the config from the repository
102    /// let config = repo.config()?;
103    ///
104    /// /// Retrieve a `Sign` implementation
105    /// let sign = SignBuilder::from_gitconfig(&repo, &config)?;
106    /// # Ok(())
107    /// # }
108    /// ```
109    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        // Variants are described in the git config documentation
118        // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgformat
119        match format.as_str() {
120            "openpgp" => {
121                // Try to retrieve the gpg program from the git
122                // configuration, moving from the least to the
123                // most specific config key, defaulting to "gpg"
124                // if nothing is explicitly defined (per git's
125                // implementation) https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
126                // https://git-scm.com/docs/git-config#Documentation/git-config.txt-gpgprogram
127                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                // Optional signing key.
133                // If 'user.signingKey' is not set, we'll use
134                // 'user.name' and 'user.email' to build a default
135                // signature in the format 'name <email>'. https://git-scm.com/docs/git-config#Documentation/git-config.txt-usersigningKey
136                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
179/// Sign commit data using `OpenPGP`
180pub struct GPGSign {
181    program: String,
182    signing_key: String,
183}
184
185impl GPGSign {
186    /// Create new [`GPGSign`] using given program and signing key.
187    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); // close stdin to not block indefinitely
220
221        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
261/// Sign commit data using `SSHDiskKeySign`
262pub struct SSHSign {
263    #[cfg(test)]
264    program: String,
265    #[cfg(test)]
266    key_path: String,
267    secret_key: PrivateKey,
268}
269
270impl SSHSign {
271    /// Create new [`SSHDiskKeySign`] for sign.
272    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        // we get gpg.program, because gpg.openpgp.program is not set
366        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        // since gpg.openpgp.program is now set as well, it is more
376        // specific than gpg.program and therefore takes precedence
377        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}