1use std::{
2 ffi::{OsStr, OsString},
3 io::Write,
4 path::PathBuf,
5 process::Stdio,
6};
7
8use bstr::{BString, ByteSlice};
9use gix_error::{Result, ResultExt, bail, corruption, message, validation};
10
11use crate::{Commit, CommitRef, Tag, TagRef, WriteTo};
12
13use super::Format;
14
15#[derive(Clone, Debug)]
17pub struct Options {
18 pub format: Format,
20 pub program: OsString,
22 pub program_arguments: Vec<OsString>,
24 pub signing_key: OsString,
28 pub environment: Vec<(OsString, OsString)>,
30}
31
32impl CommitRef<'_> {
33 pub fn sign(self, options: Options) -> Result<Commit> {
35 self.into_owned()?.sign(options)
36 }
37}
38
39impl Commit {
40 pub fn sign(mut self, options: Options) -> Result<Commit> {
45 let signature_field = crate::commit::signature_field_name(self.tree.kind());
46 self.extra_headers.retain(|(name, _)| name != signature_field);
47 let mut payload = Vec::new();
48 self.write_to(&mut payload).or_error()?;
49 let signature = sign(&payload, &options)?;
50 self.extra_headers.push((signature_field.into(), signature));
51 Ok(self)
52 }
53}
54
55impl TagRef<'_> {
56 pub fn sign(self, options: Options) -> Result<Tag> {
59 self.into_owned()?.sign(options)
60 }
61}
62
63impl Tag {
64 pub fn sign(mut self, options: Options) -> Result<Tag> {
69 self.signature = None;
70 let mut payload = Vec::new();
71 self.write_to(&mut payload).or_error()?;
72 payload.push(b'\n');
75 self.signature = Some(sign(&payload, &options)?);
76 Ok(self)
77 }
78}
79
80fn sign(payload: &[u8], options: &Options) -> Result<BString> {
81 match options.format {
82 Format::OpenPgp | Format::X509 => sign_gpg(payload, options),
83 Format::Ssh => sign_ssh(payload, options),
84 }
85}
86
87fn command(options: &Options) -> gix_command::Prepare {
88 options.environment.iter().fold(
89 gix_command::prepare(&options.program).args(&options.program_arguments),
90 |command, (key, value)| command.env(key, value),
91 )
92}
93
94fn sign_gpg(payload: &[u8], options: &Options) -> Result<BString> {
95 if options.signing_key.is_empty() {
96 bail!(validation("A signing key is required"));
97 }
98 let output = run(
99 command(options)
100 .args([OsStr::new("--status-fd=2"), OsStr::new("-bsau")])
101 .arg(&options.signing_key)
102 .stdin(Stdio::piped())
103 .stdout(Stdio::piped())
104 .stderr(Stdio::piped()),
105 &options.program,
106 payload,
107 )?;
108 if !output.status.success() {
109 bail!(program_failure(&options.program, output));
110 }
111 if !output
112 .stderr
113 .lines()
114 .any(|line| line.starts_with(b"[GNUPG:] SIG_CREATED "))
115 {
116 bail!(corruption("The OpenPGP/X.509 signer did not report SIG_CREATED"));
117 }
118 Ok(strip_cr_before_lf(output.stdout).into())
119}
120
121fn sign_ssh(payload: &[u8], options: &Options) -> Result<BString> {
122 if options.signing_key.is_empty() {
123 bail!(validation("A signing key is required"));
124 }
125 let mut literal_key_file = None;
126 let literal_key = options
127 .signing_key
128 .to_str()
129 .and_then(|key| is_literal_ssh_key(key.as_bytes()));
130 let (key, literal) = match literal_key {
131 Some(key) => {
132 let mut file = secure_temporary_file()?;
133 write_temporary(&mut file, key)?;
134 let path = temporary_path(&mut file)?;
135 literal_key_file = Some(file);
136 (path.into_os_string(), true)
137 }
138 None => (options.signing_key.clone(), false),
140 };
141 let key = super::ssh_path_argument(std::path::Path::new(&key))?;
142 let mut payload_file = secure_temporary_file()?;
143 write_temporary(&mut payload_file, payload)?;
144 let payload_path = temporary_path(&mut payload_file)?;
145 let mut signature_path = payload_path.as_os_str().to_owned();
146 signature_path.push(".sig");
147 let signature_path = PathBuf::from(signature_path);
148 let mut command = command(options).args(["-Y", "sign", "-n", "git", "-f"]).arg(key);
149 if literal {
150 command = command.arg("-U");
151 }
152 let output = command
153 .arg(super::ssh_path_argument(&payload_path)?)
154 .stdin(Stdio::null())
155 .stdout(Stdio::piped())
156 .stderr(Stdio::piped())
157 .spawn()
158 .or_raise(|| message("Could not execute signing program").with_program(&options.program))?
159 .wait_with_output()
160 .or_raise(|| message("Could not communicate with signing program").with_program(&options.program))?;
161 drop(literal_key_file);
162 if !output.status.success() {
163 bail!(program_failure(&options.program, output));
164 }
165 let signature = std::fs::read(&signature_path).or_raise(|| corruption("The SSH signer produced no signature"));
166 let _ = std::fs::remove_file(signature_path);
167 Ok(strip_cr_before_lf(signature?).into())
168}
169
170pub fn is_literal_ssh_key(key: &[u8]) -> Option<&[u8]> {
175 key.strip_prefix(b"key::")
176 .or_else(|| key.starts_with(b"ssh-").then_some(key))
177}
178
179fn secure_temporary_file() -> Result<gix_tempfile::Handle<gix_tempfile::handle::Writable>> {
181 gix_tempfile::new(
182 std::env::temp_dir(),
183 gix_tempfile::ContainingDirectory::Exists,
184 gix_tempfile::AutoRemove::Tempfile,
185 )
186 .or_raise(|| message("Could not create or write a temporary signing file"))
187}
188
189fn write_temporary(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>, data: &[u8]) -> Result {
190 file.with_mut(|file| file.write_all(data))
191 .or_raise(|| message("Could not create or write a temporary signing file"))?
192 .or_raise(|| message("Could not create or write a temporary signing file"))
193}
194
195fn temporary_path(file: &mut gix_tempfile::Handle<gix_tempfile::handle::Writable>) -> Result<PathBuf> {
196 file.with_mut(|file| file.path().to_owned())
197 .or_raise(|| message("Could not create or write a temporary signing file"))
198}
199
200fn program_failure(program: &OsStr, output: std::process::Output) -> gix_error::Message {
201 message("Signing program failed")
202 .with_program(program)
203 .with_exit_status(output.status)
204 .with("stdout", output.stdout)
205 .with("stderr", output.stderr)
206}
207
208fn run(command: gix_command::Prepare, program: &OsStr, input: &[u8]) -> Result<std::process::Output> {
209 let mut child = command
210 .spawn()
211 .or_raise(|| message("Could not execute signing program").with_program(program))?;
212 child
213 .stdin
214 .take()
215 .expect("configured as piped")
216 .write_all(input)
217 .or_raise(|| message("Could not communicate with signing program").with_program(program))?;
218 child
219 .wait_with_output()
220 .or_raise(|| message("Could not communicate with signing program").with_program(program))
221}
222
223fn strip_cr_before_lf(input: Vec<u8>) -> Vec<u8> {
227 let mut output = Vec::with_capacity(input.len());
228 let mut bytes = input.into_iter().peekable();
229 while let Some(byte) = bytes.next() {
230 if byte != b'\r' || bytes.peek() != Some(&b'\n') {
231 output.push(byte);
232 }
233 }
234 output
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn missing_signing_program_is_reported_only_in_metadata() -> gix_testtools::TestResult {
243 let directory = gix_testtools::tempfile::TempDir::new()?;
244 let program = directory.path().join("missing signer");
245 for format in [Format::OpenPgp, Format::X509, Format::Ssh] {
246 let err = sign(
247 b"payload",
248 &Options {
249 format,
250 program: program.as_os_str().into(),
251 program_arguments: Vec::new(),
252 signing_key: "unused".into(),
253 environment: Vec::new(),
254 },
255 )
256 .expect_err("the signing program does not exist in the disposable directory");
257 let diagnostic = err
258 .iter_errors()
259 .find_map(|err| err.downcast_ref::<gix_error::Message>())
260 .expect("the spawn failure has a diagnostic message");
261 assert_eq!(
262 diagnostic.message, "Could not execute signing program",
263 "the prose identifies signing without repeating the program pathname"
264 );
265 assert_eq!(
266 diagnostic.values.get("program"),
267 Some(&gix_error::MetadataValue::from(program.as_path())),
268 "program metadata retains the exact signer pathname"
269 );
270 }
271 Ok(())
272 }
273
274 #[test]
275 fn programs_with_spaces_are_invoked_directly() -> gix_testtools::TestResult {
276 let program = OsStr::new("a directory/signer");
277 let command: std::process::Command = command(&Options {
278 format: Format::OpenPgp,
279 program: program.into(),
280 program_arguments: vec!["argument with spaces".into()],
281 signing_key: "key".into(),
282 environment: Vec::new(),
283 })
284 .try_into()?;
285
286 assert_eq!(
287 command.get_program(),
288 program,
289 "a signer pathname is passed directly instead of being interpreted by a shell"
290 );
291 assert_eq!(
292 command.get_args().collect::<Vec<_>>(),
293 [OsStr::new("argument with spaces")],
294 "signer arguments remain separate from the program pathname"
295 );
296 Ok(())
297 }
298}