cert-store 0.1.0

CLI-based certificate store. Inspired by Password Store.
use std::fmt::Write as _;
use std::io::BufRead;
use std::io::BufReader;
use std::io::BufWriter;
use std::io::Read as _;
use std::io::Write as _;
use std::path::Path;
use std::process::Command;
use std::process::Stdio;

use anyhow::anyhow;

use crate::git;
use crate::gpg;

const RECIPIENTS: &str = ".recipients";

pub fn init_recipients(store_dir: impl AsRef<Path>) -> anyhow::Result<()> {
    println!("Choose GPG public keys that will be able to decrypt the data:");
    let public_keys = gpg::list_keys()?;
    if public_keys.is_empty() {
        return Err(anyhow!("There no keys in GPG store"));
    }
    for (i, (id, user)) in public_keys.iter().enumerate() {
        let n = i + 1;
        println!("{n:>3}    {id}    {user}");
    }
    let mut indices = Vec::new();
    'outer: loop {
        indices.clear();
        print!("Type space-separated row numbers: ");
        std::io::stdout().flush()?;
        let mut answer = String::new();
        std::io::stdin().lock().read_line(&mut answer)?;
        for s in answer.split_whitespace() {
            let number: usize = match s.parse() {
                Ok(n) => n,
                Err(e) => {
                    eprintln!("{s:?}: {e}");
                    continue 'outer;
                }
            };
            if number == 0 || number >= public_keys.len() {
                eprintln!("Out-of-range number: {s:?}");
                continue 'outer;
            }
            indices.push(number - 1);
        }
        break;
    }
    let gpg_id_file = store_dir.as_ref().join(RECIPIENTS);
    eprintln!("Generating recipients file {}", gpg_id_file.display());
    let mut file = BufWriter::new(fs::File::create(&gpg_id_file)?);
    let mut commit_message = "Added recipients: ".to_string();
    for (iteration, i) in indices.iter().copied().enumerate() {
        let (public_key, user) = &public_keys[i];
        writeln!(&mut file, "{}", public_key)?;
        if iteration == 0 {
            write!(&mut commit_message, "{public_key} ({user})")?;
        } else {
            write!(&mut commit_message, ", {public_key} ({user})")?;
        }
    }
    drop(file);
    git::add_and_commit(store_dir, [&gpg_id_file], &commit_message)?;
    Ok(())
}

pub fn recipients(store_dir: impl AsRef<Path>) -> anyhow::Result<Vec<String>> {
    let file = fs::File::open(store_dir.as_ref().join(RECIPIENTS))?;
    let reader = BufReader::new(file);
    let mut recipients = Vec::new();
    for line in reader.lines() {
        let line = line?;
        recipients.push(line.trim().to_string());
    }
    Ok(recipients)
}

pub fn encrypt(
    data: &[u8],
    output_file: impl AsRef<Path>,
    recipients: impl IntoIterator<Item = impl AsRef<str>>,
) -> anyhow::Result<()> {
    let mut command = Command::new("gpg");
    command.stdin(Stdio::piped());
    command.arg("--quiet");
    command.arg("--encrypt");
    command.arg("--output");
    command.arg(output_file.as_ref());
    for recipient in recipients.into_iter() {
        command.arg("--recipient");
        command.arg(recipient.as_ref());
    }
    let mut child = command.spawn()?;
    if let Some(mut stdin) = child.stdin.take() {
        stdin.write_all(data)?;
        drop(stdin);
    }
    let status = child.wait()?;
    if !status.success() {
        return Err(anyhow!("Encryption failed"));
    }
    Ok(())
}

pub fn decrypt(input_file: impl AsRef<Path>) -> anyhow::Result<String> {
    let mut command = Command::new("gpg");
    command.stdout(Stdio::piped());
    command.arg("--quiet");
    command.arg("--decrypt");
    command.arg(input_file.as_ref());
    let mut child = command.spawn()?;
    let mut buf = Vec::new();
    if let Some(mut stdout) = child.stdout.take() {
        stdout.read_to_end(&mut buf)?;
        drop(stdout);
    }
    let status = child.wait()?;
    if !status.success() {
        return Err(anyhow!("Decryption failed"));
    }
    Ok(String::from_utf8(buf)?)
}

pub fn list_keys() -> anyhow::Result<Vec<(String, String)>> {
    let invalid = || anyhow!("Invalid `gpg --list-keys --with-colons` output");
    let mut command = Command::new("gpg");
    command.stdin(Stdio::null());
    command.stdout(Stdio::piped());
    command.args(["--list-keys", "--with-colons"]);
    let mut child = command.spawn()?;
    let mut public_keys = Vec::new();
    if let Some(stdout) = child.stdout.take() {
        let reader = BufReader::new(stdout);
        let mut public_key = None;
        for line in reader.lines() {
            let line = line?;
            let mut columns = line.split(':');
            let kind = columns.next().ok_or_else(invalid)?;
            match kind {
                "tru" | "fpr" | "sub" => continue,
                "pub" => public_key = Some(columns.nth(3).ok_or_else(invalid)?.to_owned()),
                "uid" => {
                    let Some(public_key) = public_key.take() else {
                        continue;
                    };
                    let user = columns.nth(8).ok_or_else(invalid)?;
                    public_keys.push((public_key, user.to_owned()));
                }
                _ => {}
            }
        }
    }
    let status = child.wait()?;
    if !status.success() {
        return Err(anyhow!("Decryption failed"));
    }
    Ok(public_keys)
}