1use std::{
2 ffi::{OsStr, OsString},
3 io::Write,
4 path::PathBuf,
5 process::Stdio,
6};
7
8use bstr::{BString, ByteSlice};
9
10use crate::{Commit, CommitRef, Tag, TagRef, WriteTo};
11
12use super::Format;
13
14#[derive(Clone, Debug)]
16pub struct Options {
17 pub format: Format,
19 pub program: OsString,
21 pub program_arguments: Vec<OsString>,
23 pub signing_key: OsString,
27 pub environment: Vec<(OsString, OsString)>,
29}
30
31#[derive(Debug, thiserror::Error)]
33#[expect(missing_docs)]
34pub enum Error {
35 #[error(transparent)]
36 Decode(#[from] crate::decode::Error),
37 #[error(transparent)]
38 Encode(#[from] std::io::Error),
39 #[error("A signing key is required")]
40 MissingSigningKey,
41 #[error("Could not create or write a temporary signing file")]
42 TemporaryFile(#[source] std::io::Error),
43 #[error("Could not execute signing program {program:?}")]
44 Spawn {
45 program: OsString,
46 #[source]
47 source: std::io::Error,
48 },
49 #[error("Could not communicate with signing program {program:?}")]
50 Communicate {
51 program: OsString,
52 #[source]
53 source: std::io::Error,
54 },
55 #[error("Signing program {program:?} failed: {output}")]
56 Failed { program: OsString, output: BString },
57 #[error("The OpenPGP/X.509 signer did not report SIG_CREATED")]
58 MissingSignatureConfirmation,
59 #[error("The SSH signer produced no signature")]
60 MissingSshSignature(#[source] std::io::Error),
61}
62
63impl CommitRef<'_> {
64 pub fn sign(self, options: Options) -> Result<Commit, Error> {
66 self.into_owned()?.sign(options)
67 }
68}
69
70impl Commit {
71 pub fn sign(mut self, options: Options) -> Result<Commit, Error> {
73 let signature_field = crate::commit::signature_field_name(self.tree.kind());
74 self.extra_headers.retain(|(name, _)| name != signature_field);
75 let mut payload = Vec::new();
76 self.write_to(&mut payload)?;
77 let signature = sign(&payload, &options)?;
78 self.extra_headers.push((signature_field.into(), signature));
79 Ok(self)
80 }
81}
82
83impl TagRef<'_> {
84 pub fn sign(self, options: Options) -> Result<Tag, Error> {
87 self.into_owned()?.sign(options)
88 }
89}
90
91impl Tag {
92 pub fn sign(mut self, options: Options) -> Result<Tag, Error> {
94 self.signature = None;
95 let mut payload = Vec::new();
96 self.write_to(&mut payload)?;
97 payload.push(b'\n');
100 self.signature = Some(sign(&payload, &options)?);
101 Ok(self)
102 }
103}
104
105fn sign(payload: &[u8], options: &Options) -> Result<BString, Error> {
106 match options.format {
107 Format::OpenPgp | Format::X509 => sign_gpg(payload, options),
108 Format::Ssh => sign_ssh(payload, options),
109 }
110}
111
112fn command(options: &Options) -> gix_command::Prepare {
113 options.environment.iter().fold(
114 gix_command::prepare(&options.program)
115 .command_may_be_shell_script()
116 .args(&options.program_arguments),
117 |command, (key, value)| command.env(key, value),
118 )
119}
120
121fn sign_gpg(payload: &[u8], options: &Options) -> Result<BString, Error> {
122 if options.signing_key.is_empty() {
123 return Err(Error::MissingSigningKey);
124 }
125 let output = run(
126 command(options)
127 .args([OsStr::new("--status-fd=2"), OsStr::new("-bsau")])
128 .arg(&options.signing_key)
129 .stdin(Stdio::piped())
130 .stdout(Stdio::piped())
131 .stderr(Stdio::piped()),
132 &options.program,
133 payload,
134 )?;
135 if !output.status.success() {
136 return Err(Error::Failed {
137 program: options.program.clone(),
138 output: output.stderr.into(),
139 });
140 }
141 if !output
142 .stderr
143 .lines()
144 .any(|line| line.starts_with(b"[GNUPG:] SIG_CREATED "))
145 {
146 return Err(Error::MissingSignatureConfirmation);
147 }
148 Ok(strip_cr_before_lf(output.stdout).into())
149}
150
151fn sign_ssh(payload: &[u8], options: &Options) -> Result<BString, Error> {
152 if options.signing_key.is_empty() {
153 return Err(Error::MissingSigningKey);
154 }
155 let mut literal_key_file = None;
156 let literal_key = options
157 .signing_key
158 .to_str()
159 .and_then(|key| is_literal_ssh_key(key.as_bytes()));
160 let (key, literal) = match literal_key {
161 Some(key) => {
162 let mut file = secure_temporary_file()?;
163 write_temporary(&mut file, key)?;
164 let path = temporary_path(&mut file)?;
165 literal_key_file = Some(file);
166 (path.into_os_string(), true)
167 }
168 None => (options.signing_key.clone(), false),
170 };
171 let mut payload_file = secure_temporary_file()?;
172 write_temporary(&mut payload_file, payload)?;
173 let payload_path = temporary_path(&mut payload_file)?;
174 let mut signature_path = payload_path.as_os_str().to_owned();
175 signature_path.push(".sig");
176 let signature_path = PathBuf::from(signature_path);
177 let mut command = command(options).args(["-Y", "sign", "-n", "git", "-f"]).arg(key);
178 if literal {
179 command = command.arg("-U");
180 }
181 let output = command
182 .arg(&payload_path)
183 .stdin(Stdio::null())
184 .stdout(Stdio::piped())
185 .stderr(Stdio::piped())
186 .spawn()
187 .map_err(|source| Error::Spawn {
188 program: options.program.clone(),
189 source,
190 })?
191 .wait_with_output()
192 .map_err(|source| Error::Communicate {
193 program: options.program.clone(),
194 source,
195 })?;
196 drop(literal_key_file);
197 if !output.status.success() {
198 return Err(Error::Failed {
199 program: options.program.clone(),
200 output: output.stderr.into(),
201 });
202 }
203 let signature = std::fs::read(&signature_path).map_err(Error::MissingSshSignature);
204 let _ = std::fs::remove_file(signature_path);
205 Ok(strip_cr_before_lf(signature?).into())
206}
207
208pub fn is_literal_ssh_key(key: &[u8]) -> Option<&[u8]> {
213 key.strip_prefix(b"key::")
214 .or_else(|| key.starts_with(b"ssh-").then_some(key))
215}
216
217fn secure_temporary_file() -> Result<gix_tempfile::Handle<gix_tempfile::handle::Writable>, Error> {
219 gix_tempfile::new(
220 std::env::temp_dir(),
221 gix_tempfile::ContainingDirectory::Exists,
222 gix_tempfile::AutoRemove::Tempfile,
223 )
224 .map_err(Error::TemporaryFile)
225}
226
227fn write_temporary(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>, data: &[u8]) -> Result<(), Error> {
228 file.with_mut(|file| file.write_all(data))
229 .map_err(Error::TemporaryFile)?
230 .map_err(Error::TemporaryFile)
231}
232
233fn temporary_path(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>) -> Result<PathBuf, Error> {
234 file.with_mut(|file| file.path().to_owned())
235 .map_err(Error::TemporaryFile)
236}
237
238fn run(command: gix_command::Prepare, program: &OsStr, input: &[u8]) -> Result<std::process::Output, Error> {
239 let mut child = command.spawn().map_err(|source| Error::Spawn {
240 program: program.to_owned(),
241 source,
242 })?;
243 child
244 .stdin
245 .take()
246 .expect("configured as piped")
247 .write_all(input)
248 .map_err(|source| Error::Communicate {
249 program: program.to_owned(),
250 source,
251 })?;
252 child.wait_with_output().map_err(|source| Error::Communicate {
253 program: program.to_owned(),
254 source,
255 })
256}
257
258fn strip_cr_before_lf(input: Vec<u8>) -> Vec<u8> {
262 let mut output = Vec::with_capacity(input.len());
263 let mut bytes = input.into_iter().peekable();
264 while let Some(byte) = bytes.next() {
265 if byte != b'\r' || bytes.peek() != Some(&b'\n') {
266 output.push(byte);
267 }
268 }
269 output
270}