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)
}