envio 0.8.0

A secure command-line tool for managing environment variables
use std::any::Any;
use std::io::Write;
use std::process::{Command, Stdio};

use serde::{Deserialize, Serialize};

use crate::{
    EnvMap,
    cipher::{Cipher, CipherKind, EncryptedContent},
    error::{Error, Result},
};

pub fn check_gpg() -> Result<()> {
    match Command::new("gpg").arg("--version").output() {
        Ok(_) => Ok(()),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::Cipher(
            "gpg not found, please install it on your system".to_string(),
        )),
        Err(e) => Err(Error::Cipher(format!("failed to probe gpg: {e}"))),
    }
}

#[derive(Serialize, Deserialize, Default, Clone)]
struct Metadata {
    key_fingerprint: String,
}

#[derive(Clone)]
pub struct GPG {
    metadata: Metadata,
}

impl GPG {
    pub fn new(key_fingerprint: String) -> Self {
        GPG {
            metadata: Metadata { key_fingerprint },
        }
    }

    pub fn set_key_fingerprint(&mut self, key_fingerprint: String) {
        self.metadata.key_fingerprint = key_fingerprint;
    }

    pub fn get_key_fingerprint(&self) -> String {
        self.metadata.key_fingerprint.clone()
    }
}

impl Cipher for GPG {
    fn kind(&self) -> CipherKind {
        CipherKind::GPG
    }

    fn encrypt(&mut self, envs: &EnvMap) -> Result<EncryptedContent> {
        check_gpg()?;

        let data = envs.as_bytes()?;

        let mut gpg_process = Command::new("gpg")
            .arg("--recipient")
            .arg(&self.metadata.key_fingerprint)
            .arg("--encrypt")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .map_err(|e| Error::Cipher(format!("failed to spawn gpg: {e}")))?;

        let stdin = match gpg_process.stdin.as_mut() {
            Some(stdin) => stdin,
            None => {
                return Err(Error::Io(std::io::Error::other("failed to open stdin")));
            }
        };

        stdin.write_all(&data)?;

        let output = gpg_process.wait_with_output()?;

        if !output.status.success() {
            return Err(Error::Cipher(format!(
                "gpg encrypt failed (exit {})",
                output.status
            )));
        }

        Ok(EncryptedContent::Bytes(output.stdout))
    }

    fn decrypt(&self, encrypted_data: &EncryptedContent) -> Result<EnvMap> {
        check_gpg()?;

        let mut gpg_process = Command::new("gpg")
            .arg("--yes")
            .arg("--quiet")
            .arg("--decrypt")
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::null())
            .spawn()
            .map_err(|e| Error::Cipher(format!("failed to spawn gpg: {e}")))?;

        let stdin = match gpg_process.stdin.as_mut() {
            Some(stdin) => stdin,
            None => {
                return Err(Error::Msg("failed to open stdin".to_string()));
            }
        };

        stdin.write_all(&encrypted_data.as_bytes()?)?;

        let output = gpg_process.wait_with_output()?;

        if !output.status.success() {
            return Err(Error::Cipher(format!(
                "gpg decrypt failed (exit {})",
                output.status
            )));
        }

        Ok(output.stdout.into())
    }

    fn export_metadata(&self) -> Option<serde_json::Value> {
        serde_json::to_value(self.metadata.clone()).ok()
    }

    fn import_metadata(&mut self, data: serde_json::Value) -> Result<()> {
        self.metadata = serde_json::from_value(data)?;

        Ok(())
    }

    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }
}

pub fn get_gpg_keys() -> Result<Vec<(String, String)>> {
    check_gpg()?;

    let output = Command::new("gpg")
        .args(["--list-keys", "--with-colons"])
        .output()
        .map_err(|e| Error::Cipher(format!("failed to execute gpg: {e}")))?;

    if !output.status.success() {
        return Err(Error::Cipher(format!(
            "gpg --list-keys failed (exit {})",
            output.status
        )));
    }

    let stdout = String::from_utf8(output.stdout)
        .map_err(|_| Error::Msg("failed to parse GPG output as UTF-8".to_string()))?;

    let mut available_keys: Vec<(String, String)> = Vec::new();
    let mut current_fingerprint: Option<String> = None;
    let mut current_uid: Option<String> = None;

    for line in stdout.lines() {
        let fields: Vec<&str> = line.split(':').collect();

        match fields.first().copied() {
            Some("pub") | Some("sec") => {
                if let (Some(fp), Some(uid)) = (current_fingerprint.take(), current_uid.take()) {
                    available_keys.push((uid, fp));
                }
            }

            Some("fpr") => {
                if current_fingerprint.is_none()
                    && let Some(&fp) = fields.get(9)
                    && !fp.is_empty()
                {
                    current_fingerprint = Some(fp.to_string());
                }
            }

            Some("uid") => {
                if current_uid.is_none()
                    && let Some(&uid) = fields.get(9)
                    && !uid.is_empty()
                {
                    current_uid = Some(uid.to_string());
                }
            }

            _ => {}
        }
    }

    if let (Some(fp), Some(uid)) = (current_fingerprint, current_uid) {
        available_keys.push((uid, fp));
    }

    Ok(available_keys)
}