use std::path::Path;
use crate::error::{Error, Result};
use crate::git;
use crate::util::subprocess::spawn_clean;
pub fn is_available() -> bool {
crate::util::subprocess::is_available("gpg")
}
pub fn default_recipient(dir: &Path) -> Result<Option<String>> {
git::signing_key(dir)
}
pub fn resolve_recipients(explicit: &[String], cwd: &Path) -> Result<Vec<String>> {
if !explicit.is_empty() {
return Ok(explicit.to_vec());
}
if let Some(key) = default_recipient(cwd)? {
return Ok(vec![key]);
}
Err(Error::NoGpgRecipient)
}
pub fn list_secret_keys() -> Result<Vec<(String, String)>> {
let output = spawn_clean("gpg")
.args(["--list-secret-keys", "--with-colons", "--batch"])
.output()
.map_err(|e| Error::Gpg(format!("failed to run gpg --list-secret-keys: {e}")))?;
if !output.status.success() {
return Ok(vec![]);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut keys = Vec::new();
let mut current_key_id = None;
for line in stdout.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields[0] == "sec" {
current_key_id = fields.get(4).map(|s| s.to_string());
} else if fields[0] == "uid"
&& let (Some(key_id), Some(uid)) = (current_key_id.take(), fields.get(9))
{
keys.push((key_id, uid.to_string()));
}
}
Ok(keys)
}
pub fn key_recipients(path: &Path) -> Result<Vec<String>> {
let output = spawn_clean("gpg")
.args(["--list-packets", "--batch"])
.arg(path)
.output()
.map_err(|e| Error::Gpg(format!("failed to run gpg --list-packets: {e}")))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
let mut keys = Vec::new();
for line in combined.lines() {
if let Some(pos) = line.find("keyid ") {
let after = &line[pos + 6..];
if let Some(key_id) = after.split_whitespace().next()
&& !key_id.is_empty()
{
keys.push(key_id.to_string());
}
}
}
Ok(keys)
}
pub fn gpg_encrypt(data: &[u8], recipients: &[String]) -> Result<Vec<u8>> {
if !is_available() {
return Err(Error::GpgNotAvailable);
}
if recipients.is_empty() {
return Err(Error::NoGpgRecipient);
}
let mut cmd = spawn_clean("gpg");
cmd.arg("--encrypt")
.arg("--armor")
.arg("--batch")
.arg("--yes")
.arg("--trust-model")
.arg("always");
for r in recipients {
cmd.arg("--recipient").arg(r);
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| Error::Gpg(format!("failed to spawn gpg: {e}")))?;
{
use std::io::Write;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| Error::Gpg("gpg stdin not available".into()))?;
stdin
.write_all(data)
.map_err(|e| Error::Gpg(format!("failed to write to gpg stdin: {e}")))?;
}
let output = child
.wait_with_output()
.map_err(|e| Error::Gpg(format!("gpg wait failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Gpg(format!("gpg --encrypt failed: {stderr}")));
}
Ok(output.stdout)
}
pub fn gpg_decrypt(data: &[u8]) -> Result<Vec<u8>> {
if !is_available() {
return Err(Error::GpgNotAvailable);
}
let mut cmd = spawn_clean("gpg");
cmd.arg("--decrypt")
.arg("--batch")
.arg("--yes")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| Error::Gpg(format!("failed to spawn gpg: {e}")))?;
{
use std::io::Write;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| Error::Gpg("gpg stdin not available".into()))?;
stdin
.write_all(data)
.map_err(|e| Error::Gpg(format!("failed to write to gpg stdin: {e}")))?;
}
let output = child
.wait_with_output()
.map_err(|e| Error::Gpg(format!("gpg wait failed: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::Gpg(format!("gpg --decrypt failed: {stderr}")));
}
Ok(output.stdout)
}
pub fn wrap_key_gpg(aes_key: &[u8], recipients: &[String]) -> Result<Vec<u8>> {
gpg_encrypt(aes_key, recipients)
}
pub fn unwrap_key_gpg(blob: &[u8]) -> Result<Vec<u8>> {
gpg_decrypt(blob)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn gpg_availability_check() {
let _ = is_available();
}
#[test]
fn wrap_fails_without_recipients() {
if !is_available() {
return; }
let key = [0u8; 32];
let result = wrap_key_gpg(&key, &[]);
assert!(result.is_err());
}
#[test]
fn wrap_unwrap_round_trip() {
if !is_available() {
eprintln!("GPG not available, skipping wrap/unwrap round-trip test");
return;
}
let output = spawn_clean("gpg")
.args(["--list-keys", "--with-colons", "--batch"])
.output();
let Ok(output) = output else { return };
if !output.status.success() {
return;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let recipient = stdout
.lines()
.filter(|l| l.starts_with("uid:") || l.starts_with("pub:"))
.filter_map(|l| l.split(':').nth(4).filter(|s| !s.is_empty()))
.next();
let Some(recipient) = recipient else {
eprintln!("No GPG keys found, skipping wrap/unwrap round-trip test");
return;
};
let key = crate::crypto::aes::generate_key();
let wrapped = wrap_key_gpg(&key, &[recipient.to_string()]).unwrap();
assert!(!wrapped.is_empty());
let unwrapped = unwrap_key_gpg(&wrapped).unwrap();
assert_eq!(unwrapped, key);
}
#[test]
fn default_recipient_returns_result() {
let dir = tempfile::tempdir().unwrap();
let result = default_recipient(dir.path());
assert!(result.is_ok());
}
#[test]
fn resolve_recipients_explicit() {
let result = resolve_recipients(&["ABCD1234".to_string()], Path::new("/tmp")).unwrap();
assert_eq!(result, vec!["ABCD1234".to_string()]);
}
#[test]
fn resolve_recipients_empty_no_git_key() {
let dir = tempfile::tempdir().unwrap();
let result = resolve_recipients(&[], dir.path());
match &result {
Ok(recipients) => assert!(!recipients.is_empty()),
Err(_) => {} }
}
}