use std::env;
use std::ffi::OsString;
use std::io::{self, Read, Write};
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, ExitCode};
use crate::wire::openpgp::entity::armored_detached_signature;
use anyhow::{Context, Result};
use clap::Parser;
use crate::auth::{self, AuthOptions};
use crate::errors::{InvalidInput, Malformed, render_error_chain};
use crate::gpg::registry::{KeyName, SelectError, SigningKeyName};
use crate::gpg::{agent, selection_error, signer::TurnkeySigner, unix_now};
const CANNOT_EXEC: u8 = 2;
const DEFAULT_PROGRAM: &str = "gpg";
const ENVIRONMENT_HINT: &str = "set by TK_PROFILE";
const SIGNING_KEY_REMEDY: &str = "set user.signingkey to the key fingerprint";
pub enum Invocation {
Sign {
status_fd: Option<String>,
key: Option<String>,
},
Passthrough(Vec<String>),
}
impl Invocation {
pub fn parse(args: Vec<String>) -> Option<Self> {
let first = args.first()?;
let gpg_shaped = first.starts_with("--status-fd")
|| first.starts_with("--keyid-format")
|| matches!(first.as_str(), "-bsau" | "-bsa");
if !gpg_shaped {
return None;
}
let signed: Option<Self> = {
let mut status_fd = None;
let mut key = None;
let mut signing = false;
let mut rest = args.iter();
loop {
let Some(arg) = rest.next() else {
break signing.then_some(Self::Sign { status_fd, key });
};
match arg.as_str() {
"--verify" => break None,
"--status-fd" => status_fd = Some(rest.next().cloned().unwrap_or_default()),
"-bsau" => {
signing = true;
key = rest.next().cloned();
}
"-bsa" => signing = true,
other => {
if let Some(value) = other.strip_prefix("--status-fd=") {
status_fd = Some(value.to_string());
}
}
}
}
};
Some(signed.unwrap_or(Self::Passthrough(args)))
}
}
#[derive(Parser)]
struct ShimOptions {
#[command(flatten)]
auth: AuthOptions,
}
impl ShimOptions {
fn from_environment() -> Result<Self> {
Self::try_parse_from(["tk"]).map_err(|error| {
let rendered = error.to_string();
let first = rendered.lines().next().unwrap_or_default();
let detail = first.strip_prefix("error: ").unwrap_or(first);
Malformed::new(format!("{detail} ({ENVIRONMENT_HINT})"), error).into()
})
}
}
enum StatusWriter {
Stderr,
Discard,
}
impl StatusWriter {
fn parse(status_fd: Option<String>) -> Result<Self> {
match status_fd.as_deref() {
None => Ok(Self::Discard),
Some("2") => Ok(Self::Stderr),
Some(_) => Err(InvalidInput("unsupported --status-fd; git uses 2".to_string()).into()),
}
}
fn line(&self, line: &str) -> Result<()> {
match self {
Self::Stderr => writeln!(io::stderr(), "{line}"),
Self::Discard => Ok(()),
}
.context("write a gpg status line")
}
}
pub async fn run(invocation: Invocation) -> ExitCode {
let (status_fd, key) = match invocation {
Invocation::Passthrough(args) => return passthrough(args),
Invocation::Sign { status_fd, key } => (status_fd, key),
};
let signed = async {
let status = StatusWriter::parse(status_fd)?;
sign(status, key).await
}
.await;
match signed {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
let _ = writeln!(io::stderr(), "error: {}", render_error_chain(&error));
ExitCode::FAILURE
}
}
}
async fn sign(status: StatusWriter, key: Option<String>) -> Result<()> {
let signature = match env::var_os(agent::SOCKET_ENV).filter(|v| !v.is_empty()) {
Some(socket) => {
let key = match key {
Some(key) => Some(
key.parse::<SigningKeyName>()
.with_context(|| format!("parse the signing key {key}"))?,
),
None => None,
};
let mut payload = Vec::new();
io::stdin()
.take((agent::MAX_PAYLOAD_LEN + 1) as u64)
.read_to_end(&mut payload)
.context("read the payload to sign from stdin")?;
agent::sign(Path::new(&socket), key.as_ref(), &payload).await?
}
None => {
let mut payload = Vec::new();
io::stdin()
.read_to_end(&mut payload)
.context("read the payload to sign from stdin")?;
let options = ShimOptions::from_environment()?;
let (entry, client) = auth::open_gpg_key(&options.auth, key.map(KeyName::from))
.await?
.map_err(git_selection_error)?;
let now = unix_now()?;
armored_detached_signature(
entry.key.signing,
&payload,
&TurnkeySigner::new(&client, entry.organization_id),
now,
)
.await?
}
};
status.line("[GNUPG:] BEGIN_SIGNING")?;
let mut stdout = io::stdout();
write!(stdout, "{signature}").context("write the signature to stdout")?;
stdout.flush().context("write the signature to stdout")?;
status.line(&format!(
"[GNUPG:] SIG_CREATED D 19 8 00 {} {}",
signature.created(),
signature.fingerprint()
))
}
fn git_selection_error(error: SelectError) -> anyhow::Error {
match &error {
SelectError::Empty { .. } => selection_error(error, SIGNING_KEY_REMEDY),
SelectError::Unnamed { .. }
| SelectError::NoMatch { .. }
| SelectError::Ambiguous { .. } => {
InvalidInput(format!("{error}; {SIGNING_KEY_REMEDY}")).into()
}
}
}
fn passthrough(args: Vec<String>) -> ExitCode {
let program = env::var_os("TK_GPG_PROGRAM")
.filter(|value| !value.is_empty())
.unwrap_or_else(|| OsString::from(DEFAULT_PROGRAM));
let error = Command::new(&program).args(args).exec();
let _ = writeln!(
io::stderr(),
"error: cannot run {}: {error}; install GnuPG or set TK_GPG_PROGRAM",
Path::new(&program).display()
);
ExitCode::from(CANNOT_EXEC)
}
#[cfg(test)]
mod tests {
use super::*;
fn message(error: anyhow::Error) -> String {
error
.downcast_ref::<InvalidInput>()
.expect("a rejected value should be invalid input")
.0
.clone()
}
#[test]
fn only_descriptor_two_is_written_and_every_other_value_is_rejected() {
assert!(matches!(
StatusWriter::parse(None).expect("a call that names no descriptor wants no lines"),
StatusWriter::Discard
));
assert!(matches!(
StatusWriter::parse(Some("2".to_string())).expect("git's descriptor is written"),
StatusWriter::Stderr
));
for value in ["abc", "", "1", "3"] {
let Err(error) = StatusWriter::parse(Some(value.to_string())) else {
panic!("{value:?} should be rejected")
};
assert_eq!(message(error), "unsupported --status-fd; git uses 2");
}
}
}