//! `bxctl` command-line parsing: the subcommands, the shared `USAGE` and `ChatOptions`. use std::path::{Path, PathBuf}; use proto::SessionId; // Shown on stderr for any usage error. It names every subcommand and the flags they share, so a // single line tells the owner what they can type. pub const USAGE: &str = "\ usage: bxctl [options] bxctl chat [--socket PATH] [--admin-socket PATH] [--session ID] [--say TEXT] [--no-thinking] [--json] Talk to loopd. bxctl approvals [--admin-socket PATH] List the approvals waiting for a decision. bxctl approve [--admin-socket PATH] Approve approval . bxctl refuse [--reason TEXT] [--admin-socket PATH] Refuse approval , recording a reason. bxctl grants check [--admin-socket PATH] Check that the grants load. bxctl audit verify [--home DIR] Verify the audit log against the grants."; // The options every `chat` call carries. `socket` is the loop socket; `admin_socket` is the admin // socket the other subcommands use. Both default to the paths under `$BOXMAKER_HOME`. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChatOptions { pub socket: PathBuf, pub admin_socket: PathBuf, pub session: Option, pub show_thinking: bool, pub say: Option, pub json: bool, } impl ChatOptions { pub fn default(home: &Path) -> Self { ChatOptions { socket: home.join("run").join("loop").join("loop.sock"), admin_socket: default_admin_socket(home), session: None, show_thinking: true, say: None, json: false, } } } // Why `parse` rejected the arguments. There is only one kind: the arguments were wrong. #[derive(Debug, PartialEq, Eq)] pub struct UsageError; impl std::fmt::Display for UsageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "invalid arguments") } } impl std::error::Error for UsageError {} // What the owner asked for. `Chat` carries its options; every admin subcommand carries the admin // socket to use (defaulted from `$BOXMAKER_HOME`) plus whatever only it needs. #[derive(Debug, PartialEq, Eq)] pub enum Command { Chat(ChatOptions), Approvals { admin_socket: PathBuf, }, Approve { admin_socket: PathBuf, approval: u64, }, Refuse { admin_socket: PathBuf, approval: u64, reason: Option, }, GrantsCheck { admin_socket: PathBuf, }, AuditVerify { home: PathBuf, }, } pub fn parse(args: &[String], home: &Path) -> Result { let (command, rest) = match args.split_first() { Some((c, r)) => (c.as_str(), r), None => return Err(UsageError), }; match command { "chat" => parse_chat(rest, home), "approvals" => parse_approvals(rest, home), "approve" => parse_approve(rest, home), "refuse" => parse_refuse(rest, home), "grants" => parse_grants(rest, home), "audit" => parse_audit(rest, home), // A flag before the command, or any unknown command, is not valid. _ => Err(UsageError), } } fn default_admin_socket(home: &Path) -> PathBuf { home.join("run").join("owner-broker").join("admin.sock") } fn parse_chat(args: &[String], home: &Path) -> Result { let mut opts = ChatOptions::default(home); let mut i = 0; while i < args.len() { match args[i].as_str() { "--socket" => opts.socket = PathBuf::from(value_at(args, i)?), "--admin-socket" => opts.admin_socket = PathBuf::from(value_at(args, i)?), "--session" => opts.session = Some(parse_session(&value_at(args, i)?)?), "--say" => opts.say = Some(value_at(args, i)?), "--no-thinking" => { opts.show_thinking = false; i += 1; continue; } "--json" => { opts.json = true; i += 1; continue; } _ => return Err(UsageError), } i += 2; } Ok(Command::Chat(opts)) } fn parse_approvals(args: &[String], home: &Path) -> Result { let mut admin_socket = default_admin_socket(home); let mut i = 0; while i < args.len() { match args[i].as_str() { "--admin-socket" => admin_socket = PathBuf::from(value_at(args, i)?), // Anything else is a stray argument or an unknown flag. _ => return Err(UsageError), } i += 2; } Ok(Command::Approvals { admin_socket }) } fn parse_approve(args: &[String], home: &Path) -> Result { let mut admin_socket = default_admin_socket(home); let mut approval = None; let mut i = 0; while i < args.len() { match args[i].as_str() { "--admin-socket" => { admin_socket = PathBuf::from(value_at(args, i)?); i += 2; continue; } other => { if approval.is_some() { return Err(UsageError); } approval = Some(parse_id(other)?); i += 1; } } } match approval { Some(approval) => Ok(Command::Approve { admin_socket, approval, }), None => Err(UsageError), } } fn parse_refuse(args: &[String], home: &Path) -> Result { let mut admin_socket = default_admin_socket(home); let mut approval = None; let mut reason = None; let mut i = 0; while i < args.len() { match args[i].as_str() { "--admin-socket" => { admin_socket = PathBuf::from(value_at(args, i)?); i += 2; continue; } "--reason" => { if reason.is_some() { return Err(UsageError); } reason = Some(verify_reason(&value_at(args, i)?)?); i += 2; continue; } other => { if approval.is_some() { return Err(UsageError); } approval = Some(parse_id(other)?); i += 1; } } } match approval { Some(approval) => Ok(Command::Refuse { admin_socket, approval, reason, }), None => Err(UsageError), } } fn parse_grants(args: &[String], home: &Path) -> Result { let rest = match args.first().map(String::as_str) { Some("check") => &args[1..], _ => return Err(UsageError), }; let mut admin_socket = default_admin_socket(home); let mut i = 0; while i < rest.len() { match rest[i].as_str() { "--admin-socket" => admin_socket = PathBuf::from(value_at(rest, i)?), _ => return Err(UsageError), } i += 2; } Ok(Command::GrantsCheck { admin_socket }) } fn parse_audit(args: &[String], home: &Path) -> Result { let rest = match args.first().map(String::as_str) { Some("verify") => &args[1..], _ => return Err(UsageError), }; let mut home = home.to_path_buf(); let mut i = 0; while i < rest.len() { match rest[i].as_str() { "--home" => home = PathBuf::from(value_at(rest, i)?), _ => return Err(UsageError), } i += 2; } Ok(Command::AuditVerify { home }) } // The argument after the flag at `i`, taken as the value even when it looks like a flag, so a value // is a value. fn value_at(args: &[String], i: usize) -> Result { args.get(i + 1).cloned().ok_or(UsageError) } // An approval id is ASCII digits that fit in u64 and nothing else; `str::parse::` alone would // accept `+41`, so the digit check comes first. fn parse_id(value: &str) -> Result { if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { return Err(UsageError); } value.parse::().map_err(|_| UsageError) } // A session id is a valid id, and one that is purely numeric must fit in u64 so it is never // mistaken for an approval id. fn parse_session(value: &str) -> Result { match SessionId::new(value) { Ok(id) => { if value.bytes().all(|b| b.is_ascii_digit()) && value.parse::().is_err() { return Err(UsageError); } Ok(id) } Err(_) => Err(UsageError), } } // A refuse reason must be present; its content is unvalidated on the client. fn verify_reason(reason: &str) -> Result { if reason.is_empty() { return Err(UsageError); } Ok(reason.to_string()) }