regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::io::{self, Write};

use serde::Serialize;

use super::args::ClientCommand;
use crate::{
    domain::errors::{AgentError, AgentResult, ErrorCode},
    pairing::{ApprovedClient, ClientId, ClientStore, FileClientStore, PairingStoreError},
};

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ApprovedBrowsersState {
    clients: Vec<ApprovedClient>,
}

impl ApprovedBrowsersState {
    pub(crate) fn new(clients: Vec<ApprovedClient>) -> Self {
        Self { clients }
    }

    pub(crate) fn clients(&self) -> &[ApprovedClient] {
        &self.clients
    }
}

impl Default for ApprovedBrowsersState {
    fn default() -> Self {
        Self::new(Vec::new())
    }
}

pub(crate) trait ApprovedBrowserAdmin {
    fn list(&self) -> Result<Vec<ApprovedClient>, PairingStoreError>;
    fn revoke(&mut self, id: &ClientId) -> Result<bool, PairingStoreError>;
}

impl ApprovedBrowserAdmin for FileClientStore {
    fn list(&self) -> Result<Vec<ApprovedClient>, PairingStoreError> {
        ClientStore::list(self)
    }

    fn revoke(&mut self, id: &ClientId) -> Result<bool, PairingStoreError> {
        ClientStore::revoke(self, id)
    }
}

pub(crate) trait PairingConfirmation {
    fn confirm(&mut self, browser: &ApprovedClient) -> AgentResult<bool>;
}

pub(crate) struct StdinPairingConfirmation;

impl PairingConfirmation for StdinPairingConfirmation {
    fn confirm(&mut self, browser: &ApprovedClient) -> AgentResult<bool> {
        eprint!(
            "Revoke approved browser {} ({})? [y/N] ",
            safe_human_label(&browser.label),
            short_id(&browser.id)
        );
        io::stderr()
            .flush()
            .map_err(|_| safe_error(ErrorCode::InvalidMessage, "failed to write confirmation"))?;
        let mut answer = String::new();
        io::stdin()
            .read_line(&mut answer)
            .map_err(|_| safe_error(ErrorCode::InvalidMessage, "failed to read confirmation"))?;
        Ok(matches!(
            answer.trim().to_ascii_lowercase().as_str(),
            "y" | "yes"
        ))
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PairingCommandOutcome {
    Completed,
    Cancelled,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct PairingCommandOptions<'a> {
    pub(crate) server_id: &'a str,
    pub(crate) json: bool,
    pub(crate) non_interactive: bool,
    pub(crate) assume_yes: bool,
}

#[derive(Serialize)]
struct ApprovedBrowserJson<'a> {
    client_id: &'a str,
    label: &'a str,
    created_at: chrono::DateTime<chrono::Utc>,
    last_used_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Serialize)]
struct ApprovedBrowserListJson<'a> {
    server_id: &'a str,
    clients: Vec<ApprovedBrowserJson<'a>>,
}

pub(crate) fn run_client_command<W: Write>(
    admin: &mut dyn ApprovedBrowserAdmin,
    command: &ClientCommand,
    options: PairingCommandOptions<'_>,
    confirmation: &mut dyn PairingConfirmation,
    output: &mut W,
) -> AgentResult<PairingCommandOutcome> {
    match command {
        ClientCommand::List => {
            let clients = admin.list().map_err(map_store_error)?;
            write_list(options.server_id, &clients, options.json, output)?;
            Ok(PairingCommandOutcome::Completed)
        }
        ClientCommand::Revoke { client_id } => {
            let clients = admin.list().map_err(map_store_error)?;
            let target = clients
                .iter()
                .find(|client| client.id == *client_id)
                .ok_or_else(|| {
                    safe_error(ErrorCode::InvalidMessage, "approved browser was not found")
                })?;

            if options.non_interactive && !options.assume_yes {
                return Err(safe_error(
                    ErrorCode::InvalidMessage,
                    "non-interactive browser revocation requires --yes",
                ));
            }
            if !options.non_interactive && !confirmation.confirm(target)? {
                write_cancelled(options.json, output)?;
                return Ok(PairingCommandOutcome::Cancelled);
            }

            if !admin.revoke(client_id).map_err(map_store_error)? {
                return Err(safe_error(
                    ErrorCode::InvalidMessage,
                    "approved browser was not found",
                ));
            }
            write_revoked(client_id, options.json, output)?;
            Ok(PairingCommandOutcome::Completed)
        }
    }
}

pub(crate) fn short_id(id: &ClientId) -> &str {
    &id.as_str()[..8]
}

pub(crate) fn safe_human_label(label: &str) -> String {
    label
        .chars()
        .flat_map(|character| {
            if character.is_control() {
                character.escape_default().collect::<Vec<_>>()
            } else {
                vec![character]
            }
        })
        .collect()
}

fn write_list<W: Write>(
    server_id: &str,
    clients: &[ApprovedClient],
    json: bool,
    output: &mut W,
) -> AgentResult<()> {
    if json {
        let view = ApprovedBrowserListJson {
            server_id,
            clients: clients
                .iter()
                .map(|client| ApprovedBrowserJson {
                    client_id: client.id.as_str(),
                    label: &client.label,
                    created_at: client.created_at,
                    last_used_at: client.last_used_at,
                })
                .collect(),
        };
        serde_json::to_writer(&mut *output, &view).map_err(|_| output_error())?;
        writeln!(output).map_err(|_| output_error())?;
        return Ok(());
    }

    writeln!(output, "Approved browsers").map_err(|_| output_error())?;
    if clients.is_empty() {
        writeln!(output, "  No approved browsers yet.").map_err(|_| output_error())?;
    }
    for client in clients {
        writeln!(
            output,
            "  {}  {}  created {}  last used {}",
            short_id(&client.id),
            safe_human_label(&client.label),
            client.created_at.to_rfc3339(),
            client.last_used_at.to_rfc3339()
        )
        .map_err(|_| output_error())?;
    }
    Ok(())
}

fn write_cancelled<W: Write>(json: bool, output: &mut W) -> AgentResult<()> {
    if json {
        writeln!(output, "{{\"revoked\":false}}").map_err(|_| output_error())
    } else {
        writeln!(output, "Browser revocation cancelled.").map_err(|_| output_error())
    }
}

fn write_revoked<W: Write>(id: &ClientId, json: bool, output: &mut W) -> AgentResult<()> {
    if json {
        serde_json::to_writer(
            &mut *output,
            &serde_json::json!({ "revoked": true, "client_id": id.as_str() }),
        )
        .map_err(|_| output_error())?;
        writeln!(output).map_err(|_| output_error())
    } else {
        writeln!(output, "Revoked approved browser {}.", short_id(id)).map_err(|_| output_error())
    }
}

fn map_store_error(error: PairingStoreError) -> AgentError {
    let message = match error {
        PairingStoreError::IdentityStorage | PairingStoreError::Randomness => {
            "server identity storage is invalid or unavailable"
        }
        PairingStoreError::Storage => "approved browser storage is unavailable",
        PairingStoreError::Corrupt | PairingStoreError::Duplicate => {
            "approved browser storage is malformed or unsupported"
        }
        PairingStoreError::CommitUncertain => {
            "browser revocation may have been published; inspect the approved browser list before retrying"
        }
        PairingStoreError::InvalidLabel
        | PairingStoreError::ClientLimit
        | PairingStoreError::BindingMismatch
        | PairingStoreError::InvalidBinding => "approved browser storage operation failed",
    };
    safe_error(ErrorCode::PairingStorageFailed, message)
}

pub(crate) fn pairing_store_error(error: PairingStoreError) -> AgentError {
    map_store_error(error)
}

fn output_error() -> AgentError {
    safe_error(
        ErrorCode::InvalidMessage,
        "failed to write approved browser output",
    )
}

fn safe_error(code: ErrorCode, message: &'static str) -> AgentError {
    AgentError::new(code, message)
}