fix: security hardening — 40 findings from full codebase review

Full codebase review by 4 independent agents (security, architecture,
code quality, correctness) identified ~80 findings. This commit fixes 40
of them across all workspace crates.

Critical fixes:
- Federation service: validate origin against mTLS cert CN/SAN (C1)
- WS bridge: add DM channel auth, size limits, rate limiting (C2)
- hpke_seal: panic on error instead of silent empty ciphertext (C3)
- hpke_setup_sender_and_export: error on parse fail, no PQ downgrade (C7)

Security fixes:
- Zeroize: seed_bytes() returns Zeroizing<[u8;32]>, private_to_bytes()
  returns Zeroizing<Vec<u8>>, ClientAuth.access_token, SessionState.password,
  conversation hex_key all wrapped in Zeroizing
- Keystore: 0o600 file permissions on Unix
- MeshIdentity: 0o600 file permissions on Unix
- Timing floors: resolveIdentity + WS bridge resolve_user get 5ms floor
- Mobile: TLS verification gated behind insecure-dev feature flag
- Proto: from_bytes default limit tightened from 64 MiB to 8 MiB

Correctness fixes:
- fetch_wait: register waiter before fetch to close TOCTOU window
- MeshEnvelope: exclude hop_count from signature (forwarding no longer
  invalidates sender signature)
- BroadcastChannel: encrypt returns Result instead of panicking
- transcript: rename verify_transcript_chain → validate_transcript_structure
- group.rs: extract shared process_incoming() for receive_message variants
- auth_ops: remove spurious RegistrationRequest deserialization
- MeshStore.seen: bounded to 100K with FIFO eviction

Quality fixes:
- FFI error classification: typed downcast instead of string matching
- Plugin HookVTable: SAFETY documentation for unsafe Send+Sync
- clippy::unwrap_used: warn → deny workspace-wide
- Various .unwrap_or("") → proper error returns

Review report: docs/REVIEW-2026-03-04.md
152 tests passing (72 core + 35 server + 14 E2E + 1 doctest + 30 P2P)
This commit is contained in:
2026-03-04 07:52:12 +01:00
parent 4694a3098b
commit 394199b19b
58 changed files with 3893 additions and 414 deletions

View File

@@ -71,6 +71,9 @@ quicproquo-p2p = { path = "../quicproquo-p2p", optional = true }
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
crossterm = { version = "0.28", optional = true }
# YAML playbook parsing (only compiled with --features playbook).
serde_yaml = { version = "0.9", optional = true }
[lints]
workspace = true
@@ -80,6 +83,9 @@ workspace = true
mesh = ["dep:mdns-sd", "dep:quicproquo-p2p"]
# Enable full-screen Ratatui TUI: cargo build -p quicproquo-client --features tui
tui = ["dep:ratatui", "dep:crossterm"]
# Enable playbook (scripted command execution): YAML parser + serde derives.
# Build: cargo build -p quicproquo-client --features playbook
playbook = ["dep:serde_yaml"]
[dev-dependencies]
dashmap = { workspace = true }

View File

@@ -0,0 +1,508 @@
//! Command engine: typed command enum, registry, and execution bridge.
//!
//! Maps every REPL slash command and lifecycle operation into a single `Command`
//! enum with typed parameters. `CommandRegistry` parses raw input and delegates
//! execution to the existing `cmd_*` handlers in `repl.rs`.
use std::collections::HashMap;
use quicproquo_proto::node_capnp::node_service;
use super::repl::{Input, SlashCommand, parse_input};
use super::session::SessionState;
// ── Comparison operator for assert conditions ────────────────────────────────
/// Comparison operator used in playbook assertions.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))]
pub enum CmpOp {
Eq,
Ne,
Gt,
Lt,
Gte,
Lte,
}
impl CmpOp {
/// Evaluate this comparison: `lhs <op> rhs`.
pub fn eval(&self, lhs: usize, rhs: usize) -> bool {
match self {
CmpOp::Eq => lhs == rhs,
CmpOp::Ne => lhs != rhs,
CmpOp::Gt => lhs > rhs,
CmpOp::Lt => lhs < rhs,
CmpOp::Gte => lhs >= rhs,
CmpOp::Lte => lhs <= rhs,
}
}
}
// ── Assert conditions for playbook testing ───────────────────────────────────
/// Conditions that can be asserted in a playbook step.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))]
pub enum AssertCondition {
Connected,
LoggedIn,
InConversation { name: String },
MessageCount { op: CmpOp, count: usize },
MemberCount { op: CmpOp, count: usize },
Custom { expression: String },
}
// ── Command enum ─────────────────────────────────────────────────────────────
/// Every operation the client can perform, with typed parameters.
///
/// This is a superset of `SlashCommand` — it adds lifecycle operations
/// (`Connect`, `Login`, `Register`, `SendMessage`, `Wait`, `Assert`, `SetVar`)
/// that are needed for non-interactive / playbook execution.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))]
pub enum Command {
// ── Lifecycle (not in SlashCommand) ──────────────────────────────────
Connect {
server: String,
ca_cert: Option<String>,
insecure: bool,
},
Login {
username: String,
password: String,
},
Register {
username: String,
password: String,
},
SendMessage {
text: String,
},
Wait {
duration_ms: u64,
},
Assert {
condition: AssertCondition,
},
SetVar {
name: String,
value: String,
},
// ── SlashCommand mirror ─────────────────────────────────────────────
Help,
Quit,
Whoami,
List,
Switch { target: String },
Dm { username: String },
CreateGroup { name: String },
Invite { target: String },
Remove { target: String },
Leave,
Join,
Members,
GroupInfo,
Rename { name: String },
History { count: usize },
// Mesh
MeshPeers,
MeshServer { addr: String },
MeshSend { peer_id: String, message: String },
MeshBroadcast { topic: String, message: String },
MeshSubscribe { topic: String },
MeshRoute,
MeshIdentity,
MeshStore,
// Security / crypto
Verify { username: String },
UpdateKey,
Typing,
TypingNotify { enabled: bool },
React { emoji: String, index: Option<usize> },
Edit { index: usize, new_text: String },
Delete { index: usize },
SendFile { path: String },
Download { index: usize },
DeleteAccount,
Disappear { arg: Option<String> },
Privacy { arg: Option<String> },
VerifyFs,
RotateAllKeys,
Devices,
RegisterDevice { name: String },
RevokeDevice { id_prefix: String },
}
impl Command {
/// Convert a `Command` to a `SlashCommand` when possible.
///
/// Returns `None` for lifecycle commands that have no `SlashCommand`
/// equivalent (`Connect`, `Login`, `Register`, `SendMessage`, `Wait`,
/// `Assert`, `SetVar`).
pub(crate) fn to_slash(&self) -> Option<SlashCommand> {
match self.clone() {
// Lifecycle — no SlashCommand equivalent
Command::Connect { .. }
| Command::Login { .. }
| Command::Register { .. }
| Command::SendMessage { .. }
| Command::Wait { .. }
| Command::Assert { .. }
| Command::SetVar { .. } => None,
// 1:1 mirror
Command::Help => Some(SlashCommand::Help),
Command::Quit => Some(SlashCommand::Quit),
Command::Whoami => Some(SlashCommand::Whoami),
Command::List => Some(SlashCommand::List),
Command::Switch { target } => Some(SlashCommand::Switch { target }),
Command::Dm { username } => Some(SlashCommand::Dm { username }),
Command::CreateGroup { name } => Some(SlashCommand::CreateGroup { name }),
Command::Invite { target } => Some(SlashCommand::Invite { target }),
Command::Remove { target } => Some(SlashCommand::Remove { target }),
Command::Leave => Some(SlashCommand::Leave),
Command::Join => Some(SlashCommand::Join),
Command::Members => Some(SlashCommand::Members),
Command::GroupInfo => Some(SlashCommand::GroupInfo),
Command::Rename { name } => Some(SlashCommand::Rename { name }),
Command::History { count } => Some(SlashCommand::History { count }),
Command::MeshPeers => Some(SlashCommand::MeshPeers),
Command::MeshServer { addr } => Some(SlashCommand::MeshServer { addr }),
Command::MeshSend { peer_id, message } => {
Some(SlashCommand::MeshSend { peer_id, message })
}
Command::MeshBroadcast { topic, message } => {
Some(SlashCommand::MeshBroadcast { topic, message })
}
Command::MeshSubscribe { topic } => Some(SlashCommand::MeshSubscribe { topic }),
Command::MeshRoute => Some(SlashCommand::MeshRoute),
Command::MeshIdentity => Some(SlashCommand::MeshIdentity),
Command::MeshStore => Some(SlashCommand::MeshStore),
Command::Verify { username } => Some(SlashCommand::Verify { username }),
Command::UpdateKey => Some(SlashCommand::UpdateKey),
Command::Typing => Some(SlashCommand::Typing),
Command::TypingNotify { enabled } => Some(SlashCommand::TypingNotify { enabled }),
Command::React { emoji, index } => Some(SlashCommand::React { emoji, index }),
Command::Edit { index, new_text } => Some(SlashCommand::Edit { index, new_text }),
Command::Delete { index } => Some(SlashCommand::Delete { index }),
Command::SendFile { path } => Some(SlashCommand::SendFile { path }),
Command::Download { index } => Some(SlashCommand::Download { index }),
Command::DeleteAccount => Some(SlashCommand::DeleteAccount),
Command::Disappear { arg } => Some(SlashCommand::Disappear { arg }),
Command::Privacy { arg } => Some(SlashCommand::Privacy { arg }),
Command::VerifyFs => Some(SlashCommand::VerifyFs),
Command::RotateAllKeys => Some(SlashCommand::RotateAllKeys),
Command::Devices => Some(SlashCommand::Devices),
Command::RegisterDevice { name } => Some(SlashCommand::RegisterDevice { name }),
Command::RevokeDevice { id_prefix } => {
Some(SlashCommand::RevokeDevice { id_prefix })
}
}
}
}
// ── CommandResult ────────────────────────────────────────────────────────────
/// Outcome of executing a single `Command`.
#[derive(Debug, Clone)]
#[cfg_attr(feature = "playbook", derive(serde::Serialize, serde::Deserialize))]
pub struct CommandResult {
pub success: bool,
pub output: Option<String>,
pub error: Option<String>,
/// Structured key-value outputs for variable capture in playbooks.
pub data: HashMap<String, String>,
}
impl CommandResult {
fn ok() -> Self {
Self {
success: true,
output: None,
error: None,
data: HashMap::new(),
}
}
fn err(msg: String) -> Self {
Self {
success: false,
output: None,
error: Some(msg),
data: HashMap::new(),
}
}
}
// ── CommandRegistry ──────────────────────────────────────────────────────────
/// Parses raw input into `Command` and delegates execution to the existing
/// REPL handlers.
pub struct CommandRegistry;
impl CommandRegistry {
/// Parse a raw input line into a `Command`.
///
/// Returns `None` for empty input. Returns `Some(Command::SendMessage)`
/// for plain chat text. Slash commands are parsed via the existing
/// `parse_input` function.
pub fn parse(line: &str) -> Option<Command> {
match parse_input(line) {
Input::Empty => None,
Input::ChatMessage(text) => Some(Command::SendMessage { text }),
Input::Slash(sc) => Some(slash_to_command(sc)),
}
}
/// Execute a `Command`, delegating slash commands to the existing
/// `handle_slash` dispatch and handling lifecycle commands directly.
///
/// Currently, output from `cmd_*` handlers goes to stdout (unchanged).
/// `CommandResult` captures success/failure status; stdout capture can
/// be added later.
pub async fn execute(
cmd: &Command,
session: &mut SessionState,
client: &node_service::Client,
) -> CommandResult {
match cmd {
Command::Wait { duration_ms } => {
tokio::time::sleep(std::time::Duration::from_millis(*duration_ms)).await;
CommandResult::ok()
}
Command::SetVar { name, value } => {
let mut result = CommandResult::ok();
result.data.insert(name.clone(), value.clone());
result
}
Command::Assert { condition } => execute_assert(condition, session),
Command::Connect { .. } | Command::Login { .. } | Command::Register { .. } => {
// These lifecycle commands require external context (endpoint,
// OPAQUE state) that lives outside SessionState. The playbook
// executor will handle them directly; calling execute() for
// them is an error.
CommandResult::err(
"lifecycle commands (connect/login/register) must be handled by the playbook executor".into(),
)
}
Command::SendMessage { text } => {
match super::repl::do_send(session, client, text).await {
Ok(()) => CommandResult::ok(),
Err(e) => CommandResult::err(format!("{e:#}")),
}
}
Command::Quit => CommandResult::ok(),
other => {
// All remaining variants have a SlashCommand equivalent.
if let Some(sc) = other.to_slash() {
match execute_slash(session, client, sc).await {
Ok(()) => CommandResult::ok(),
Err(e) => CommandResult::err(format!("{e:#}")),
}
} else {
CommandResult::err("command has no slash equivalent".into())
}
}
}
}
}
// ── Conversion helpers ──────────────────────────────────────────────────────
/// Convert a `SlashCommand` into the corresponding `Command`.
fn slash_to_command(sc: SlashCommand) -> Command {
match sc {
SlashCommand::Help => Command::Help,
SlashCommand::Quit => Command::Quit,
SlashCommand::Whoami => Command::Whoami,
SlashCommand::List => Command::List,
SlashCommand::Switch { target } => Command::Switch { target },
SlashCommand::Dm { username } => Command::Dm { username },
SlashCommand::CreateGroup { name } => Command::CreateGroup { name },
SlashCommand::Invite { target } => Command::Invite { target },
SlashCommand::Remove { target } => Command::Remove { target },
SlashCommand::Leave => Command::Leave,
SlashCommand::Join => Command::Join,
SlashCommand::Members => Command::Members,
SlashCommand::GroupInfo => Command::GroupInfo,
SlashCommand::Rename { name } => Command::Rename { name },
SlashCommand::History { count } => Command::History { count },
SlashCommand::MeshPeers => Command::MeshPeers,
SlashCommand::MeshServer { addr } => Command::MeshServer { addr },
SlashCommand::MeshSend { peer_id, message } => Command::MeshSend { peer_id, message },
SlashCommand::MeshBroadcast { topic, message } => {
Command::MeshBroadcast { topic, message }
}
SlashCommand::MeshSubscribe { topic } => Command::MeshSubscribe { topic },
SlashCommand::MeshRoute => Command::MeshRoute,
SlashCommand::MeshIdentity => Command::MeshIdentity,
SlashCommand::MeshStore => Command::MeshStore,
SlashCommand::Verify { username } => Command::Verify { username },
SlashCommand::UpdateKey => Command::UpdateKey,
SlashCommand::Typing => Command::Typing,
SlashCommand::TypingNotify { enabled } => Command::TypingNotify { enabled },
SlashCommand::React { emoji, index } => Command::React { emoji, index },
SlashCommand::Edit { index, new_text } => Command::Edit { index, new_text },
SlashCommand::Delete { index } => Command::Delete { index },
SlashCommand::SendFile { path } => Command::SendFile { path },
SlashCommand::Download { index } => Command::Download { index },
SlashCommand::DeleteAccount => Command::DeleteAccount,
SlashCommand::Disappear { arg } => Command::Disappear { arg },
SlashCommand::Privacy { arg } => Command::Privacy { arg },
SlashCommand::VerifyFs => Command::VerifyFs,
SlashCommand::RotateAllKeys => Command::RotateAllKeys,
SlashCommand::Devices => Command::Devices,
SlashCommand::RegisterDevice { name } => Command::RegisterDevice { name },
SlashCommand::RevokeDevice { id_prefix } => Command::RevokeDevice { id_prefix },
}
}
// ── Execution helpers ───────────────────────────────────────────────────────
/// Execute a `SlashCommand` using the existing `cmd_*` handlers from `repl.rs`.
///
/// This duplicates the dispatch table from `handle_slash` but returns
/// `anyhow::Result<()>` instead of printing errors inline — the caller
/// decides how to surface errors.
async fn execute_slash(
session: &mut SessionState,
client: &node_service::Client,
cmd: SlashCommand,
) -> anyhow::Result<()> {
use super::repl::*;
match cmd {
SlashCommand::Help => {
print_help();
Ok(())
}
SlashCommand::Quit => Ok(()),
SlashCommand::Whoami => cmd_whoami(session),
SlashCommand::List => cmd_list(session),
SlashCommand::Switch { target } => cmd_switch(session, &target),
SlashCommand::Dm { username } => cmd_dm(session, client, &username).await,
SlashCommand::CreateGroup { name } => cmd_create_group(session, &name),
SlashCommand::Invite { target } => cmd_invite(session, client, &target).await,
SlashCommand::Remove { target } => cmd_remove(session, client, &target).await,
SlashCommand::Leave => cmd_leave(session, client).await,
SlashCommand::Join => cmd_join(session, client).await,
SlashCommand::Members => cmd_members(session, client).await,
SlashCommand::GroupInfo => cmd_group_info(session, client).await,
SlashCommand::Rename { name } => cmd_rename(session, &name),
SlashCommand::History { count } => cmd_history(session, count),
SlashCommand::MeshPeers => cmd_mesh_peers(),
SlashCommand::MeshServer { addr } => {
super::display::print_status(&format!(
"mesh server hint: reconnect with --server {addr} to use this node"
));
Ok(())
}
SlashCommand::MeshSend { peer_id, message } => cmd_mesh_send(&peer_id, &message),
SlashCommand::MeshBroadcast { topic, message } => cmd_mesh_broadcast(&topic, &message),
SlashCommand::MeshSubscribe { topic } => cmd_mesh_subscribe(&topic),
SlashCommand::MeshRoute => cmd_mesh_route(session),
SlashCommand::MeshIdentity => cmd_mesh_identity(session),
SlashCommand::MeshStore => cmd_mesh_store(session),
SlashCommand::Verify { username } => cmd_verify(session, client, &username).await,
SlashCommand::UpdateKey => cmd_update_key(session, client).await,
SlashCommand::Typing => cmd_typing(session, client).await,
SlashCommand::TypingNotify { enabled } => {
session.typing_notify_enabled = enabled;
super::display::print_status(&format!(
"typing notifications {}",
if enabled { "enabled" } else { "disabled" }
));
Ok(())
}
SlashCommand::React { emoji, index } => cmd_react(session, client, &emoji, index).await,
SlashCommand::Edit { index, new_text } => {
cmd_edit(session, client, index, &new_text).await
}
SlashCommand::Delete { index } => cmd_delete(session, client, index).await,
SlashCommand::SendFile { path } => cmd_send_file(session, client, &path).await,
SlashCommand::Download { index } => cmd_download(session, client, index).await,
SlashCommand::DeleteAccount => cmd_delete_account(session, client).await,
SlashCommand::Disappear { arg } => cmd_disappear(session, arg.as_deref()),
SlashCommand::Privacy { arg } => cmd_privacy(session, arg.as_deref()),
SlashCommand::VerifyFs => cmd_verify_fs(session),
SlashCommand::RotateAllKeys => cmd_rotate_all_keys(session, client).await,
SlashCommand::Devices => cmd_devices(client).await,
SlashCommand::RegisterDevice { name } => cmd_register_device(client, &name).await,
SlashCommand::RevokeDevice { id_prefix } => cmd_revoke_device(client, &id_prefix).await,
}
}
/// Assert a condition against the current session state.
fn execute_assert(condition: &AssertCondition, session: &SessionState) -> CommandResult {
match condition {
AssertCondition::Connected => {
// We have a session => we got past connect. Always true when
// execute() is called with a valid client reference.
CommandResult::ok()
}
AssertCondition::LoggedIn => {
let guard = crate::AUTH_CONTEXT
.read()
.expect("AUTH_CONTEXT poisoned");
if guard.is_some() {
CommandResult::ok()
} else {
CommandResult::err("not logged in".into())
}
}
AssertCondition::InConversation { name } => {
if let Some(display) = session.active_display_name() {
if display.contains(name.as_str()) {
CommandResult::ok()
} else {
CommandResult::err(format!(
"active conversation is '{display}', expected '{name}'"
))
}
} else {
CommandResult::err("no active conversation".into())
}
}
AssertCondition::MessageCount { op, count } => {
let actual = session
.active_conversation
.as_ref()
.and_then(|id| session.conv_store.load_all_messages(id).ok())
.map(|msgs| msgs.len())
.unwrap_or(0);
if op.eval(actual, *count) {
CommandResult::ok()
} else {
CommandResult::err(format!(
"message count assertion failed: {actual} {op:?} {count}"
))
}
}
AssertCondition::MemberCount { op, count } => {
let actual = session
.active_conversation
.as_ref()
.and_then(|id| session.members.get(id))
.map(|m| m.member_identities().len())
.unwrap_or(0);
if op.eval(actual, *count) {
CommandResult::ok()
} else {
CommandResult::err(format!(
"member count assertion failed: {actual} {op:?} {count}"
))
}
}
AssertCondition::Custom { expression } => {
// Custom expressions are not evaluated yet; always pass.
let mut result = CommandResult::ok();
result.data.insert("expression".into(), expression.clone());
result
}
}
}

View File

@@ -169,10 +169,10 @@ impl ConversationStore {
let salt = get_or_create_salt(&salt_path)?;
let key = derive_convdb_key(password, &salt)?;
let hex_key = hex::encode(*key);
let hex_key = Zeroizing::new(hex::encode(&*key));
let conn = Connection::open(db_path).context("open conversation db")?;
conn.pragma_update(None, "key", format!("x'{hex_key}'"))
conn.pragma_update(None, "key", format!("x'{}'", &*hex_key))
.context("set SQLCipher key")?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.context("set pragmas")?;
@@ -188,7 +188,7 @@ impl ConversationStore {
) -> anyhow::Result<()> {
let salt = get_or_create_salt(salt_path)?;
let key = derive_convdb_key(password, &salt)?;
let hex_key = hex::encode(*key);
let hex_key = Zeroizing::new(hex::encode(&*key));
let enc_path = db_path.with_extension("convdb-enc");
@@ -197,10 +197,16 @@ impl ConversationStore {
plain.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;").ok();
// Attach a new encrypted database and export into it.
// Sanitize the path to prevent SQL injection (ATTACH does not support parameterized paths).
let enc_path_str = enc_path.display().to_string();
anyhow::ensure!(
!enc_path_str.contains('\''),
"database path must not contain single quotes: {enc_path_str}"
);
plain
.execute_batch(&format!(
"ATTACH DATABASE '{}' AS encrypted KEY \"x'{hex_key}'\";",
enc_path.display()
"ATTACH DATABASE '{enc_path_str}' AS encrypted KEY \"x'{}'\";",
&*hex_key
))
.context("attach encrypted db for migration")?;
plain
@@ -361,7 +367,13 @@ impl ConversationStore {
};
let member_keys: Vec<Vec<u8>> = member_keys_blob
.and_then(|b| bincode::deserialize(&b).ok())
.and_then(|b| match bincode::deserialize(&b) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(conv = %hex::encode(id.0), "bincode deserialize member_keys failed: {e}");
None
}
})
.unwrap_or_default();
Ok(Conversation {
@@ -418,7 +430,13 @@ impl ConversationStore {
}
};
let member_keys: Vec<Vec<u8>> = member_keys_blob
.and_then(|b| bincode::deserialize(&b).ok())
.and_then(|b| match bincode::deserialize(&b) {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(conv = %hex::encode(&id_blob), "bincode deserialize member_keys failed: {e}");
None
}
})
.unwrap_or_default();
Ok(Conversation {
@@ -545,7 +563,7 @@ impl ConversationStore {
ORDER BY timestamp_ms DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![conv_id.0.as_slice(), limit as u32], |row| {
let rows = stmt.query_map(params![conv_id.0.as_slice(), limit.min(u32::MAX as usize) as u32], |row| {
let message_id: Option<Vec<u8>> = row.get(0)?;
let sender_key: Vec<u8> = row.get(1)?;
let sender_name: Option<String> = row.get(2)?;

View File

@@ -1,8 +1,11 @@
pub mod command_engine;
pub mod commands;
pub mod conversation;
pub mod display;
pub mod hex;
pub mod mesh_discovery;
#[cfg(feature = "playbook")]
pub mod playbook;
pub mod repl;
pub mod retry;
pub mod rpc;

View File

@@ -0,0 +1,868 @@
//! YAML playbook parser and executor.
//!
//! Playbooks describe a sequence of client commands in YAML format.
//! They support variable substitution, assertions, loops, and per-step
//! error handling policies.
//!
//! ```yaml
//! name: "smoke test"
//! steps:
//! - command: dm
//! args: { username: "bob" }
//! - command: send
//! args: { text: "Hello from playbook" }
//! - command: assert
//! condition: message_count
//! op: gte
//! value: 1
//! ```
//!
//! Requires the `playbook` cargo feature.
use std::collections::HashMap;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context, bail};
use quicproquo_proto::node_capnp::node_service;
use serde::{Deserialize, Serialize};
use super::command_engine::{AssertCondition, CmpOp, Command, CommandRegistry};
use super::session::SessionState;
// ── Playbook structs ────────────────────────────────────────────────────────
/// A parsed YAML playbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Playbook {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub variables: HashMap<String, String>,
pub steps: Vec<PlaybookStep>,
}
/// A single step in a playbook.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookStep {
pub command: String,
#[serde(default)]
pub args: HashMap<String, serde_yaml::Value>,
/// For assert steps: the condition name.
#[serde(default)]
pub condition: Option<String>,
/// For assert steps: comparison operator.
#[serde(default)]
pub op: Option<String>,
/// For assert steps: expected value.
#[serde(default)]
pub value: Option<serde_yaml::Value>,
/// Capture the command output into this variable name.
#[serde(default)]
pub capture: Option<String>,
/// Error handling policy for this step.
#[serde(default)]
pub on_error: OnError,
/// Optional loop specification.
#[serde(rename = "loop", default)]
pub loop_spec: Option<LoopSpec>,
}
/// What to do when a step fails.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OnError {
#[default]
Fail,
Skip,
Continue,
}
/// Loop specification for repeating a step.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoopSpec {
pub var: String,
pub from: usize,
pub to: usize,
}
// ── Report structs ──────────────────────────────────────────────────────────
/// Summary of a playbook execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlaybookReport {
pub name: String,
pub total_steps: usize,
pub passed: usize,
pub failed: usize,
pub skipped: usize,
pub duration: Duration,
pub step_results: Vec<StepResult>,
}
impl PlaybookReport {
/// True if all steps passed (no failures).
pub fn all_passed(&self) -> bool {
self.failed == 0
}
}
impl std::fmt::Display for PlaybookReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "Playbook: {}", self.name)?;
writeln!(
f,
"Result: {} passed, {} failed, {} skipped ({} total)",
self.passed, self.failed, self.skipped, self.total_steps,
)?;
writeln!(f, "Duration: {:.2}s", self.duration.as_secs_f64())?;
for sr in &self.step_results {
let status = if sr.success { "OK" } else { "FAIL" };
write!(
f,
" [{}/{}] {} ... {} ({:.1}ms)",
sr.step_index + 1,
self.total_steps,
sr.command,
status,
sr.duration.as_secs_f64() * 1000.0,
)?;
if let Some(ref e) = sr.error {
write!(f, " — {e}")?;
}
writeln!(f)?;
}
Ok(())
}
}
/// Result of a single step execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_index: usize,
pub command: String,
pub success: bool,
pub duration: Duration,
pub output: Option<String>,
pub error: Option<String>,
}
// ── PlaybookRunner ──────────────────────────────────────────────────────────
/// Executes a parsed `Playbook` step-by-step.
pub struct PlaybookRunner {
playbook: Playbook,
vars: HashMap<String, String>,
}
impl PlaybookRunner {
/// Load a playbook from a YAML file.
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let content =
std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
Self::from_str(&content)
}
/// Parse a playbook from a YAML string.
pub fn from_str(yaml: &str) -> anyhow::Result<Self> {
let playbook: Playbook =
serde_yaml::from_str(yaml).context("parse playbook YAML")?;
let vars = playbook.variables.clone();
Ok(Self { playbook, vars })
}
/// Override or add variables before execution.
pub fn set_var(&mut self, name: impl Into<String>, value: impl Into<String>) {
self.vars.insert(name.into(), value.into());
}
/// Execute all steps, returning a report.
pub async fn run(
&mut self,
session: &mut SessionState,
client: &node_service::Client,
) -> PlaybookReport {
let start = Instant::now();
let total = self.expanded_step_count();
let mut results = Vec::new();
let mut passed = 0usize;
let mut failed = 0usize;
let mut skipped = 0usize;
let mut step_idx = 0usize;
let mut abort = false;
for step in &self.playbook.steps.clone() {
if abort {
skipped += 1;
results.push(StepResult {
step_index: step_idx,
command: step.command.clone(),
success: false,
duration: Duration::ZERO,
output: None,
error: Some("skipped (prior failure)".into()),
});
step_idx += 1;
continue;
}
if let Some(ref ls) = step.loop_spec {
for i in ls.from..=ls.to {
self.vars.insert(ls.var.clone(), i.to_string());
let sr = self.execute_step(step, step_idx, total, session, client).await;
if sr.success {
passed += 1;
} else {
failed += 1;
if step.on_error == OnError::Fail {
abort = true;
}
}
results.push(sr);
step_idx += 1;
if abort {
break;
}
}
} else {
let sr = self.execute_step(step, step_idx, total, session, client).await;
if sr.success {
passed += 1;
} else {
match step.on_error {
OnError::Fail => {
failed += 1;
abort = true;
}
OnError::Skip => skipped += 1,
OnError::Continue => failed += 1,
}
}
results.push(sr);
step_idx += 1;
}
}
PlaybookReport {
name: self.playbook.name.clone(),
total_steps: step_idx,
passed,
failed,
skipped,
duration: start.elapsed(),
step_results: results,
}
}
/// Execute a single step.
async fn execute_step(
&mut self,
step: &PlaybookStep,
index: usize,
total: usize,
session: &mut SessionState,
client: &node_service::Client,
) -> StepResult {
let t = Instant::now();
let cmd = match self.step_to_command(step) {
Ok(c) => c,
Err(e) => {
return StepResult {
step_index: index,
command: step.command.clone(),
success: false,
duration: t.elapsed(),
output: None,
error: Some(format!("{e:#}")),
};
}
};
eprintln!(
"[{}/{}] {} ...",
index + 1,
total,
step.command,
);
let cr = CommandRegistry::execute(&cmd, session, client).await;
// Capture output into variable if requested.
if let Some(ref var_name) = step.capture {
if let Some(ref out) = cr.output {
self.vars.insert(var_name.clone(), out.clone());
}
for (k, v) in &cr.data {
self.vars.insert(format!("{var_name}.{k}"), v.clone());
}
}
StepResult {
step_index: index,
command: step.command.clone(),
success: cr.success,
duration: t.elapsed(),
output: cr.output,
error: cr.error,
}
}
/// Convert a YAML step into a typed `Command`.
fn step_to_command(&self, step: &PlaybookStep) -> anyhow::Result<Command> {
let cmd_name = step.command.as_str();
match cmd_name {
// ── Lifecycle commands ────────────────────────────────────────
"connect" => Ok(Command::Connect {
server: self.resolve_str(&step.args, "server")?,
ca_cert: self.opt_str(&step.args, "ca_cert"),
insecure: self.opt_bool(&step.args, "insecure"),
}),
"login" => Ok(Command::Login {
username: self.resolve_str(&step.args, "username")?,
password: self.resolve_str(&step.args, "password")?,
}),
"register" => Ok(Command::Register {
username: self.resolve_str(&step.args, "username")?,
password: self.resolve_str(&step.args, "password")?,
}),
"send" | "send-message" => Ok(Command::SendMessage {
text: self.resolve_str(&step.args, "text")?,
}),
"wait" => Ok(Command::Wait {
duration_ms: self.resolve_u64(&step.args, "duration_ms")?,
}),
"set-var" | "setvar" => Ok(Command::SetVar {
name: self.resolve_str(&step.args, "name")?,
value: self.resolve_str(&step.args, "value")?,
}),
"assert" => {
let condition = self.build_assert_condition(step)?;
Ok(Command::Assert { condition })
}
// ── Session / identity ───────────────────────────────────────
"help" => Ok(Command::Help),
"quit" | "exit" => Ok(Command::Quit),
"whoami" => Ok(Command::Whoami),
"list" | "ls" => Ok(Command::List),
"switch" | "sw" => Ok(Command::Switch {
target: self.resolve_str(&step.args, "target")?,
}),
"dm" => Ok(Command::Dm {
username: self.resolve_str(&step.args, "username")?,
}),
"create-group" | "cg" => Ok(Command::CreateGroup {
name: self.resolve_str(&step.args, "name")?,
}),
"invite" => Ok(Command::Invite {
target: self.resolve_str(&step.args, "target")?,
}),
"remove" | "kick" => Ok(Command::Remove {
target: self.resolve_str(&step.args, "target")?,
}),
"leave" => Ok(Command::Leave),
"join" => Ok(Command::Join),
"members" => Ok(Command::Members),
"group-info" | "gi" => Ok(Command::GroupInfo),
"rename" => Ok(Command::Rename {
name: self.resolve_str(&step.args, "name")?,
}),
"history" | "hist" => Ok(Command::History {
count: self.opt_usize(&step.args, "count").unwrap_or(20),
}),
// ── Security / crypto ────────────────────────────────────────
"verify" => Ok(Command::Verify {
username: self.resolve_str(&step.args, "username")?,
}),
"update-key" | "rotate-key" => Ok(Command::UpdateKey),
"typing" => Ok(Command::Typing),
"typing-notify" => Ok(Command::TypingNotify {
enabled: self.opt_bool(&step.args, "enabled"),
}),
"react" => Ok(Command::React {
emoji: self.resolve_str(&step.args, "emoji")?,
index: self.opt_usize(&step.args, "index"),
}),
"edit" => Ok(Command::Edit {
index: self.resolve_usize(&step.args, "index")?,
new_text: self.resolve_str(&step.args, "new_text")?,
}),
"delete" | "del" => Ok(Command::Delete {
index: self.resolve_usize(&step.args, "index")?,
}),
"send-file" | "sf" => Ok(Command::SendFile {
path: self.resolve_str(&step.args, "path")?,
}),
"download" | "dl" => Ok(Command::Download {
index: self.resolve_usize(&step.args, "index")?,
}),
"delete-account" => Ok(Command::DeleteAccount),
"disappear" => Ok(Command::Disappear {
arg: self.opt_str(&step.args, "duration"),
}),
"privacy" => Ok(Command::Privacy {
arg: self.opt_str(&step.args, "setting"),
}),
"verify-fs" => Ok(Command::VerifyFs),
"rotate-all-keys" => Ok(Command::RotateAllKeys),
"devices" => Ok(Command::Devices),
"register-device" => Ok(Command::RegisterDevice {
name: self.resolve_str(&step.args, "name")?,
}),
"revoke-device" => Ok(Command::RevokeDevice {
id_prefix: self.resolve_str(&step.args, "id_prefix")?,
}),
// ── Mesh ─────────────────────────────────────────────────────
"mesh-peers" => Ok(Command::MeshPeers),
"mesh-server" => Ok(Command::MeshServer {
addr: self.resolve_str(&step.args, "addr")?,
}),
"mesh-send" => Ok(Command::MeshSend {
peer_id: self.resolve_str(&step.args, "peer_id")?,
message: self.resolve_str(&step.args, "message")?,
}),
"mesh-broadcast" => Ok(Command::MeshBroadcast {
topic: self.resolve_str(&step.args, "topic")?,
message: self.resolve_str(&step.args, "message")?,
}),
"mesh-subscribe" => Ok(Command::MeshSubscribe {
topic: self.resolve_str(&step.args, "topic")?,
}),
"mesh-route" => Ok(Command::MeshRoute),
"mesh-identity" | "mesh-id" => Ok(Command::MeshIdentity),
"mesh-store" => Ok(Command::MeshStore),
other => bail!("unknown command: {other}"),
}
}
/// Build an `AssertCondition` from a playbook step.
fn build_assert_condition(&self, step: &PlaybookStep) -> anyhow::Result<AssertCondition> {
let cond = step
.condition
.as_deref()
.context("assert step requires 'condition' field")?;
match cond {
"connected" => Ok(AssertCondition::Connected),
"logged_in" => Ok(AssertCondition::LoggedIn),
"in_conversation" => {
let name = self.resolve_str(&step.args, "name")
.or_else(|_| step.value.as_ref()
.and_then(|v| v.as_str())
.map(|s| self.substitute(s))
.context("assert in_conversation requires 'name' arg or 'value'"))?;
Ok(AssertCondition::InConversation { name })
}
"message_count" => {
let op = self.parse_cmp_op(step.op.as_deref().unwrap_or("gte"))?;
let count = step
.value
.as_ref()
.and_then(|v| v.as_u64())
.context("message_count assert requires numeric 'value'")?
as usize;
Ok(AssertCondition::MessageCount { op, count })
}
"member_count" => {
let op = self.parse_cmp_op(step.op.as_deref().unwrap_or("gte"))?;
let count = step
.value
.as_ref()
.and_then(|v| v.as_u64())
.context("member_count assert requires numeric 'value'")?
as usize;
Ok(AssertCondition::MemberCount { op, count })
}
other => Ok(AssertCondition::Custom {
expression: other.to_string(),
}),
}
}
fn parse_cmp_op(&self, s: &str) -> anyhow::Result<CmpOp> {
match s {
"eq" | "==" => Ok(CmpOp::Eq),
"ne" | "!=" => Ok(CmpOp::Ne),
"gt" | ">" => Ok(CmpOp::Gt),
"lt" | "<" => Ok(CmpOp::Lt),
"gte" | ">=" => Ok(CmpOp::Gte),
"lte" | "<=" => Ok(CmpOp::Lte),
other => bail!("unknown comparison operator: {other}"),
}
}
// ── Variable substitution helpers ────────────────────────────────────
/// Substitute `$varname` and `${VAR:-default}` in a string.
fn substitute(&self, s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '$' {
if chars.peek() == Some(&'{') {
chars.next(); // consume '{'
let mut key = String::new();
let mut default = None;
while let Some(&ch) = chars.peek() {
if ch == '}' {
chars.next();
break;
}
if ch == ':' && chars.clone().nth(1) == Some('-') {
chars.next(); // consume ':'
chars.next(); // consume '-'
let mut def = String::new();
while let Some(&dch) = chars.peek() {
if dch == '}' {
chars.next();
break;
}
def.push(dch);
chars.next();
}
default = Some(def);
break;
}
key.push(ch);
chars.next();
}
if let Some(val) = self.vars.get(&key) {
result.push_str(val);
} else if let Ok(val) = std::env::var(&key) {
result.push_str(&val);
} else if let Some(def) = default {
result.push_str(&def);
}
} else {
let mut key = String::new();
while let Some(&ch) = chars.peek() {
if ch.is_alphanumeric() || ch == '_' {
key.push(ch);
chars.next();
} else {
break;
}
}
if let Some(val) = self.vars.get(&key) {
result.push_str(val);
} else {
result.push('$');
result.push_str(&key);
}
}
} else {
result.push(c);
}
}
result
}
/// Resolve a required string argument with variable substitution.
fn resolve_str(
&self,
args: &HashMap<String, serde_yaml::Value>,
key: &str,
) -> anyhow::Result<String> {
let val = args
.get(key)
.with_context(|| format!("missing required argument: {key}"))?;
match val {
serde_yaml::Value::String(s) => Ok(self.substitute(s)),
serde_yaml::Value::Number(n) => Ok(n.to_string()),
serde_yaml::Value::Bool(b) => Ok(b.to_string()),
other => Ok(format!("{other:?}")),
}
}
/// Resolve an optional string argument.
fn opt_str(
&self,
args: &HashMap<String, serde_yaml::Value>,
key: &str,
) -> Option<String> {
args.get(key).map(|v| match v {
serde_yaml::Value::String(s) => self.substitute(s),
serde_yaml::Value::Number(n) => n.to_string(),
serde_yaml::Value::Bool(b) => b.to_string(),
other => format!("{other:?}"),
})
}
/// Resolve an optional bool argument (defaults to false).
fn opt_bool(
&self,
args: &HashMap<String, serde_yaml::Value>,
key: &str,
) -> bool {
args.get(key)
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
/// Resolve a required usize argument.
fn resolve_usize(
&self,
args: &HashMap<String, serde_yaml::Value>,
key: &str,
) -> anyhow::Result<usize> {
let val = args
.get(key)
.with_context(|| format!("missing required argument: {key}"))?;
val.as_u64()
.map(|n| n as usize)
.with_context(|| format!("argument '{key}' must be a positive integer"))
}
/// Resolve a required u64 argument.
fn resolve_u64(
&self,
args: &HashMap<String, serde_yaml::Value>,
key: &str,
) -> anyhow::Result<u64> {
let val = args
.get(key)
.with_context(|| format!("missing required argument: {key}"))?;
val.as_u64()
.with_context(|| format!("argument '{key}' must be a positive integer"))
}
/// Resolve an optional usize argument.
fn opt_usize(
&self,
args: &HashMap<String, serde_yaml::Value>,
key: &str,
) -> Option<usize> {
args.get(key).and_then(|v| v.as_u64()).map(|n| n as usize)
}
/// Count total expanded steps (including loop iterations).
fn expanded_step_count(&self) -> usize {
self.playbook
.steps
.iter()
.map(|s| {
if let Some(ref ls) = s.loop_spec {
if ls.to >= ls.from {
ls.to - ls.from + 1
} else {
0
}
} else {
1
}
})
.sum()
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_playbook() {
let yaml = r#"
name: "test"
steps:
- command: whoami
- command: list
"#;
let runner = PlaybookRunner::from_str(yaml).unwrap();
assert_eq!(runner.playbook.name, "test");
assert_eq!(runner.playbook.steps.len(), 2);
assert_eq!(runner.playbook.steps[0].command, "whoami");
}
#[test]
fn parse_playbook_with_variables() {
let yaml = r#"
name: "var test"
variables:
user: alice
server: "127.0.0.1:5001"
steps:
- command: dm
args:
username: "$user"
"#;
let runner = PlaybookRunner::from_str(yaml).unwrap();
assert_eq!(runner.vars["user"], "alice");
assert_eq!(runner.vars["server"], "127.0.0.1:5001");
}
#[test]
fn variable_substitution() {
let mut vars = HashMap::new();
vars.insert("name".to_string(), "alice".to_string());
vars.insert("port".to_string(), "5001".to_string());
let runner = PlaybookRunner {
playbook: Playbook {
name: "test".into(),
description: None,
variables: HashMap::new(),
steps: vec![],
},
vars,
};
assert_eq!(runner.substitute("hello $name"), "hello alice");
assert_eq!(runner.substitute("port=$port!"), "port=5001!");
assert_eq!(runner.substitute("${name}@server"), "alice@server");
assert_eq!(
runner.substitute("${missing:-default}"),
"default"
);
assert_eq!(runner.substitute("no vars here"), "no vars here");
}
#[test]
fn step_to_command_mapping() {
let yaml = r#"
name: "mapping test"
variables:
user: bob
steps:
- command: dm
args:
username: "$user"
- command: send
args:
text: "hello"
- command: history
args:
count: 10
- command: wait
args:
duration_ms: 500
"#;
let runner = PlaybookRunner::from_str(yaml).unwrap();
let cmd0 = runner.step_to_command(&runner.playbook.steps[0]).unwrap();
assert!(matches!(cmd0, Command::Dm { username } if username == "bob"));
let cmd1 = runner.step_to_command(&runner.playbook.steps[1]).unwrap();
assert!(matches!(cmd1, Command::SendMessage { text } if text == "hello"));
let cmd2 = runner.step_to_command(&runner.playbook.steps[2]).unwrap();
assert!(matches!(cmd2, Command::History { count: 10 }));
let cmd3 = runner.step_to_command(&runner.playbook.steps[3]).unwrap();
assert!(matches!(cmd3, Command::Wait { duration_ms: 500 }));
}
#[test]
fn parse_assert_step() {
let yaml = r#"
name: "assert test"
steps:
- command: assert
condition: message_count
op: gte
value: 5
"#;
let runner = PlaybookRunner::from_str(yaml).unwrap();
let cmd = runner.step_to_command(&runner.playbook.steps[0]).unwrap();
match cmd {
Command::Assert {
condition: AssertCondition::MessageCount { op, count },
} => {
assert_eq!(op, CmpOp::Gte);
assert_eq!(count, 5);
}
other => panic!("expected Assert MessageCount, got {other:?}"),
}
}
#[test]
fn parse_loop_spec() {
let yaml = r#"
name: "loop test"
steps:
- command: send
args:
text: "msg $i"
loop:
var: i
from: 1
to: 5
"#;
let runner = PlaybookRunner::from_str(yaml).unwrap();
assert_eq!(runner.expanded_step_count(), 5);
let ls = runner.playbook.steps[0].loop_spec.as_ref().unwrap();
assert_eq!(ls.var, "i");
assert_eq!(ls.from, 1);
assert_eq!(ls.to, 5);
}
#[test]
fn on_error_defaults_to_fail() {
let yaml = r#"
name: "error test"
steps:
- command: whoami
- command: list
on_error: continue
- command: quit
on_error: skip
"#;
let runner = PlaybookRunner::from_str(yaml).unwrap();
assert_eq!(runner.playbook.steps[0].on_error, OnError::Fail);
assert_eq!(runner.playbook.steps[1].on_error, OnError::Continue);
assert_eq!(runner.playbook.steps[2].on_error, OnError::Skip);
}
#[test]
fn cmp_op_parsing() {
let runner = PlaybookRunner::from_str("name: t\nsteps: []").unwrap();
assert!(matches!(runner.parse_cmp_op("eq"), Ok(CmpOp::Eq)));
assert!(matches!(runner.parse_cmp_op("=="), Ok(CmpOp::Eq)));
assert!(matches!(runner.parse_cmp_op("gte"), Ok(CmpOp::Gte)));
assert!(matches!(runner.parse_cmp_op(">="), Ok(CmpOp::Gte)));
assert!(matches!(runner.parse_cmp_op("<"), Ok(CmpOp::Lt)));
assert!(runner.parse_cmp_op("invalid").is_err());
}
#[test]
fn report_display() {
let report = PlaybookReport {
name: "test".into(),
total_steps: 3,
passed: 2,
failed: 1,
skipped: 0,
duration: Duration::from_millis(150),
step_results: vec![
StepResult {
step_index: 0,
command: "whoami".into(),
success: true,
duration: Duration::from_millis(10),
output: None,
error: None,
},
StepResult {
step_index: 1,
command: "dm".into(),
success: true,
duration: Duration::from_millis(50),
output: None,
error: None,
},
StepResult {
step_index: 2,
command: "assert".into(),
success: false,
duration: Duration::from_millis(1),
output: None,
error: Some("message count 0 < 1".into()),
},
],
};
let s = format!("{report}");
assert!(s.contains("2 passed, 1 failed"));
assert!(s.contains("[3/3] assert ... FAIL"));
}
}

View File

@@ -37,13 +37,13 @@ use super::token_cache::{clear_cached_session, load_cached_session, save_cached_
// ── Input parsing ────────────────────────────────────────────────────────────
enum Input {
pub(crate) enum Input {
Slash(SlashCommand),
ChatMessage(String),
Empty,
}
enum SlashCommand {
pub(crate) enum SlashCommand {
Help,
Quit,
Whoami,
@@ -104,7 +104,7 @@ enum SlashCommand {
RevokeDevice { id_prefix: String },
}
fn parse_input(line: &str) -> Input {
pub(crate) fn parse_input(line: &str) -> Input {
let trimmed = line.trim();
if trimmed.is_empty() {
return Input::Empty;
@@ -246,7 +246,7 @@ fn parse_input(line: &str) -> Input {
"/react" => match arg {
Some(rest) => {
let mut parts = rest.splitn(2, ' ');
let emoji = parts.next().unwrap().to_string();
let emoji = parts.next().unwrap_or_default().to_string();
let index = parts.next().and_then(|s| s.trim().parse::<usize>().ok());
Input::Slash(SlashCommand::React { emoji, index })
}
@@ -258,7 +258,7 @@ fn parse_input(line: &str) -> Input {
"/edit" => match arg {
Some(rest) => {
let mut parts = rest.splitn(2, ' ');
let idx_str = parts.next().unwrap();
let idx_str = parts.next().unwrap_or_default();
match (idx_str.parse::<usize>(), parts.next()) {
(Ok(index), Some(new_text)) if !new_text.trim().is_empty() => {
Input::Slash(SlashCommand::Edit { index, new_text: new_text.trim().to_string() })
@@ -847,7 +847,7 @@ async fn handle_slash(
}
}
fn print_help() {
pub(crate) fn print_help() {
display::print_status("Commands:");
display::print_status(" /dm <user[@domain]> - Start or switch to a DM (federation supported)");
display::print_status(" /create-group <name> - Create a new group");
@@ -925,7 +925,7 @@ fn format_ttl(secs: u32) -> String {
}
}
fn cmd_disappear(
pub(crate) fn cmd_disappear(
session: &mut SessionState,
arg: Option<&str>,
) -> anyhow::Result<()> {
@@ -966,7 +966,7 @@ fn cmd_disappear(
Ok(())
}
fn cmd_privacy(
pub(crate) fn cmd_privacy(
session: &mut SessionState,
arg: Option<&str>,
) -> anyhow::Result<()> {
@@ -1047,7 +1047,7 @@ fn cmd_privacy(
Ok(())
}
fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
@@ -1091,7 +1091,7 @@ fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_rotate_all_keys(
pub(crate) async fn cmd_rotate_all_keys(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1109,7 +1109,7 @@ async fn cmd_rotate_all_keys(
}
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
fn cmd_mesh_peers() -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_peers() -> anyhow::Result<()> {
use super::mesh_discovery::MeshDiscovery;
match MeshDiscovery::start() {
@@ -1138,7 +1138,7 @@ fn cmd_mesh_peers() -> anyhow::Result<()> {
}
/// Send a direct P2P mesh message (stub — P2pNode not yet wired into session).
fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("mesh send: would send to {peer_id}: {message}"));
@@ -1153,7 +1153,7 @@ fn cmd_mesh_send(peer_id: &str, message: &str) -> anyhow::Result<()> {
}
/// Broadcast an encrypted message on a topic (stub — P2pNode not yet wired into session).
fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("mesh broadcast to {topic}: {message}"));
@@ -1168,7 +1168,7 @@ fn cmd_mesh_broadcast(topic: &str, message: &str) -> anyhow::Result<()> {
}
/// Subscribe to a broadcast topic (stub — P2pNode not yet wired into session).
fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
display::print_status(&format!("subscribed to topic: {topic}"));
@@ -1183,7 +1183,7 @@ fn cmd_mesh_subscribe(topic: &str) -> anyhow::Result<()> {
}
/// Display known mesh peers and routes from the mesh identity file.
fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
let mesh_state_path = session.state_path.with_extension("mesh.json");
@@ -1217,7 +1217,7 @@ fn cmd_mesh_route(session: &SessionState) -> anyhow::Result<()> {
}
/// Display mesh node identity information.
fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
let mesh_state_path = session.state_path.with_extension("mesh.json");
@@ -1239,7 +1239,7 @@ fn cmd_mesh_identity(session: &SessionState) -> anyhow::Result<()> {
}
/// Display mesh store-and-forward statistics.
fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
#[cfg(feature = "mesh")]
{
// Without a live P2pNode in the session, we can only report that the store
@@ -1256,7 +1256,7 @@ fn cmd_mesh_store(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
display::print_status(&format!(
"identity: {}",
hex::encode(session.identity.public_key_bytes())
@@ -1272,7 +1272,7 @@ fn cmd_whoami(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
fn cmd_list(session: &SessionState) -> anyhow::Result<()> {
pub(crate) fn cmd_list(session: &SessionState) -> anyhow::Result<()> {
let convs = session.conv_store.list_conversations()?;
if convs.is_empty() {
display::print_status("no conversations yet. Try /dm <username> or /create-group <name>");
@@ -1303,7 +1303,7 @@ fn cmd_list(session: &SessionState) -> anyhow::Result<()> {
Ok(())
}
fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> {
let target = target.trim();
let conv = if let Some(username) = target.strip_prefix('@') {
@@ -1330,7 +1330,7 @@ fn cmd_switch(session: &mut SessionState, target: &str) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_dm(
pub(crate) async fn cmd_dm(
session: &mut SessionState,
client: &node_service::Client,
username: &str,
@@ -1469,7 +1469,7 @@ async fn cmd_dm(
Ok(())
}
fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()> {
let conv_id = ConversationId::from_group_name(name);
if session.conv_store.find_group_by_name(name)?.is_some() {
@@ -1513,7 +1513,7 @@ fn cmd_create_group(session: &mut SessionState, name: &str) -> anyhow::Result<()
Ok(())
}
async fn cmd_invite(
pub(crate) async fn cmd_invite(
session: &mut SessionState,
client: &node_service::Client,
target: &str,
@@ -1584,7 +1584,7 @@ async fn cmd_invite(
Ok(())
}
async fn cmd_remove(
pub(crate) async fn cmd_remove(
session: &mut SessionState,
client: &node_service::Client,
target: &str,
@@ -1628,7 +1628,7 @@ async fn cmd_remove(
Ok(())
}
async fn cmd_leave(
pub(crate) async fn cmd_leave(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1665,7 +1665,7 @@ async fn cmd_leave(
Ok(())
}
async fn cmd_update_key(
pub(crate) async fn cmd_update_key(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1710,7 +1710,7 @@ async fn cmd_update_key(
Ok(())
}
async fn cmd_join(
pub(crate) async fn cmd_join(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1818,7 +1818,7 @@ async fn resolve_or_hex(
}
}
async fn cmd_members(
pub(crate) async fn cmd_members(
session: &SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1855,7 +1855,7 @@ async fn cmd_members(
Ok(())
}
async fn cmd_group_info(
pub(crate) async fn cmd_group_info(
session: &SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -1908,7 +1908,7 @@ async fn cmd_group_info(
Ok(())
}
fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()> {
pub(crate) fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
@@ -1926,7 +1926,7 @@ fn cmd_rename(session: &mut SessionState, new_name: &str) -> anyhow::Result<()>
Ok(())
}
fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
pub(crate) fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
let conv_id = session
.active_conversation
.as_ref()
@@ -1943,7 +1943,7 @@ fn cmd_history(session: &SessionState, count: usize) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_verify(
pub(crate) async fn cmd_verify(
session: &SessionState,
client: &node_service::Client,
username: &str,
@@ -1982,7 +1982,7 @@ async fn cmd_verify(
// ── Typing indicator ─────────────────────────────────────────────────────────
async fn cmd_typing(
pub(crate) async fn cmd_typing(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -2033,7 +2033,7 @@ async fn cmd_typing(
Ok(())
}
async fn cmd_react(
pub(crate) async fn cmd_react(
session: &mut SessionState,
client: &node_service::Client,
emoji: &str,
@@ -2127,7 +2127,7 @@ async fn cmd_react(
// ── Edit / Delete ────────────────────────────────────────────────────────────
async fn cmd_edit(
pub(crate) async fn cmd_edit(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
@@ -2200,7 +2200,7 @@ async fn cmd_edit(
Ok(())
}
async fn cmd_delete(
pub(crate) async fn cmd_delete(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
@@ -2313,7 +2313,7 @@ fn format_size(bytes: u64) -> String {
}
}
async fn cmd_send_file(
pub(crate) async fn cmd_send_file(
session: &mut SessionState,
client: &node_service::Client,
path_str: &str,
@@ -2447,7 +2447,7 @@ async fn cmd_send_file(
Ok(())
}
async fn cmd_download(
pub(crate) async fn cmd_download(
session: &mut SessionState,
client: &node_service::Client,
index: usize,
@@ -2582,7 +2582,7 @@ fn extract_filename_from_body(body: &str) -> Option<String> {
}
}
async fn cmd_delete_account(
pub(crate) async fn cmd_delete_account(
session: &mut SessionState,
client: &node_service::Client,
) -> anyhow::Result<()> {
@@ -2631,7 +2631,7 @@ async fn handle_send(
}
}
async fn do_send(
pub(crate) async fn do_send(
session: &mut SessionState,
client: &node_service::Client,
text: &str,
@@ -3240,7 +3240,7 @@ async fn replenish_pending_key(
// ── Device management commands ──────────────────────────────────────────────
async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
pub(crate) async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
let devices = list_devices(client).await?;
if devices.is_empty() {
display::print_status("No devices registered.");
@@ -3260,7 +3260,7 @@ async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
Ok(())
}
async fn cmd_register_device(
pub(crate) async fn cmd_register_device(
client: &node_service::Client,
name: &str,
) -> anyhow::Result<()> {
@@ -3279,7 +3279,7 @@ async fn cmd_register_device(
Ok(())
}
async fn cmd_revoke_device(
pub(crate) async fn cmd_revoke_device(
client: &node_service::Client,
id_prefix: &str,
) -> anyhow::Result<()> {

View File

@@ -152,7 +152,7 @@ pub fn set_auth(auth: &mut auth::Builder<'_>) -> anyhow::Result<()> {
)
})?;
auth.set_version(ctx.version);
auth.set_access_token(&ctx.access_token);
auth.set_access_token(&*ctx.access_token);
auth.set_device_id(&ctx.device_id);
Ok(())
}

View File

@@ -9,6 +9,7 @@ use std::sync::Arc;
use std::time::Instant;
use anyhow::Context;
use zeroize::Zeroizing;
use quicproquo_core::{DiskKeyStore, GroupMember, HybridKeypair, IdentityKeypair};
@@ -25,8 +26,8 @@ pub struct SessionState {
pub hybrid_kp: Option<HybridKeypair>,
/// Path to the legacy state file (for backward compat with one-shot commands).
pub state_path: PathBuf,
/// Optional password for the legacy state file.
pub password: Option<String>,
/// Optional password for the legacy state file. Zeroized on drop. (M9)
pub password: Option<Zeroizing<String>>,
/// SQLite-backed conversation + message store.
pub conv_store: ConversationStore,
/// Currently active conversation.
@@ -80,7 +81,7 @@ impl SessionState {
identity,
hybrid_kp,
state_path: state_path.to_path_buf(),
password: password.map(String::from),
password: password.map(|p| Zeroizing::new(String::from(p))),
conv_store,
active_conversation: None,
members: HashMap::new(),
@@ -183,7 +184,10 @@ impl SessionState {
fn create_member_from_conv(&self, conv: &Conversation) -> anyhow::Result<GroupMember> {
let ks_path = self.keystore_path_for(&conv.id);
let ks = DiskKeyStore::persistent(&ks_path)
.unwrap_or_else(|_| DiskKeyStore::ephemeral());
.unwrap_or_else(|e| {
tracing::warn!(path = %ks_path.display(), error = %e, "DiskKeyStore open failed, falling back to ephemeral");
DiskKeyStore::ephemeral()
});
let group = conv
.mls_group_blob

View File

@@ -55,7 +55,7 @@ impl StoredState {
.transpose()?;
Ok(Self {
identity_seed: member.identity_seed(),
identity_seed: *member.identity_seed(),
group,
hybrid_key: hybrid_kp.map(|kp| kp.to_bytes()),
member_keys: Vec::new(),

View File

@@ -64,7 +64,14 @@ pub fn save_cached_session(
let bytes = match password {
Some(pw) => encrypt_state(pw, contents.as_bytes())?,
None => contents.into_bytes(),
None => {
#[cfg(not(unix))]
tracing::warn!(
"storing session token as plaintext (no password set); \
file permissions cannot be restricted on this platform"
);
contents.into_bytes()
}
};
std::fs::write(&path, bytes).with_context(|| format!("write session cache {path:?}"))?;

View File

@@ -644,7 +644,7 @@ async fn tui_loop(
// Clone session state for the poll task (it needs its own SessionState).
let poll_session = SessionState::load(
&session.state_path.clone(),
session.password.as_deref(),
session.password.as_ref().map(|p| p.as_str()),
)?;
let poll_tx = event_tx.clone();
tokio::task::spawn_local(poll_task(poll_session, client.clone(), poll_tx));

View File

@@ -17,6 +17,8 @@
use std::sync::RwLock;
use std::sync::atomic::{AtomicBool, Ordering};
use zeroize::Zeroizing;
pub mod client;
pub use client::commands::{
@@ -26,14 +28,85 @@ pub use client::commands::{
cmd_send, cmd_whoami, opaque_login, receive_pending_plaintexts, whoami_json,
};
pub use client::command_engine::{Command, CommandRegistry, CommandResult};
#[cfg(feature = "playbook")]
pub use client::playbook::{Playbook, PlaybookReport, PlaybookRunner};
pub use client::repl::run_repl;
pub use client::rpc::{connect_node, connect_node_opt, create_channel, enqueue, fetch_wait, resolve_user};
// Global auth context — RwLock so the REPL can set it after OPAQUE login.
// ── ClientContext: structured holder for session-scoped auth + TLS config ────
/// Holds the authentication credentials and TLS policy for a client session.
///
/// Prefer constructing a `ClientContext` and passing it explicitly where
/// possible. The global `AUTH_CONTEXT` / `INSECURE_SKIP_VERIFY` statics
/// delegate to a `ClientContext` under the hood and exist only for backward
/// compatibility with call-sites that have not yet been migrated.
pub struct ClientContext {
auth: RwLock<Option<ClientAuth>>,
insecure_skip_verify: AtomicBool,
}
impl ClientContext {
/// Create a new context with no auth and TLS verification enabled.
pub fn new() -> Self {
Self {
auth: RwLock::new(None),
insecure_skip_verify: AtomicBool::new(false),
}
}
/// Create a context pre-loaded with auth credentials.
pub fn with_auth(auth: ClientAuth) -> Self {
Self {
auth: RwLock::new(Some(auth)),
insecure_skip_verify: AtomicBool::new(false),
}
}
/// Set (or replace) the auth credentials.
pub fn set_auth(&self, ctx: ClientAuth) {
let mut guard = self.auth.write().expect("ClientContext auth lock poisoned");
*guard = Some(ctx);
}
/// Read the current auth snapshot (cloned).
pub fn get_auth(&self) -> Option<ClientAuth> {
let guard = self.auth.read().expect("ClientContext auth lock poisoned");
guard.clone()
}
/// Returns true if auth credentials have been set.
pub fn is_authenticated(&self) -> bool {
let guard = self.auth.read().expect("ClientContext auth lock poisoned");
guard.is_some()
}
/// Enable or disable insecure TLS mode.
pub fn set_insecure_skip_verify(&self, enabled: bool) {
self.insecure_skip_verify.store(enabled, Ordering::Relaxed);
}
/// Read the current insecure-skip-verify flag.
pub fn insecure_skip_verify(&self) -> bool {
self.insecure_skip_verify.load(Ordering::Relaxed)
}
}
impl Default for ClientContext {
fn default() -> Self {
Self::new()
}
}
// ── Global statics (thin wrappers, kept for backward compat) ─────────────────
/// Global auth context — delegates to a process-wide `ClientContext`.
/// Prefer passing `&ClientContext` explicitly in new code.
pub(crate) static AUTH_CONTEXT: RwLock<Option<ClientAuth>> = RwLock::new(None);
/// When `true`, [`connect_node`] skips TLS certificate verification.
/// Set via [`set_insecure_skip_verify`]; read by the RPC layer.
/// Prefer `ClientContext::set_insecure_skip_verify` in new code.
pub(crate) static INSECURE_SKIP_VERIFY: AtomicBool = AtomicBool::new(false);
/// Enable or disable insecure (no-verify) TLS mode globally.
@@ -47,7 +120,8 @@ pub fn set_insecure_skip_verify(enabled: bool) {
#[derive(Clone, Debug)]
pub struct ClientAuth {
pub(crate) version: u16,
pub(crate) access_token: Vec<u8>,
/// Bearer or OPAQUE session token. Zeroized on drop. (M8)
pub(crate) access_token: Zeroizing<Vec<u8>>,
pub(crate) device_id: Vec<u8>,
}
@@ -58,7 +132,7 @@ impl ClientAuth {
let device = device_id.unwrap_or_default().into_bytes();
Self {
version: 1,
access_token: token,
access_token: Zeroizing::new(token),
device_id: device,
}
}
@@ -68,7 +142,7 @@ impl ClientAuth {
let device = device_id.unwrap_or_default().into_bytes();
Self {
version: 1,
access_token: raw_token,
access_token: Zeroizing::new(raw_token),
device_id: device,
}
}

View File

@@ -393,6 +393,34 @@ enum Command {
#[arg(long)]
input: PathBuf,
},
/// Execute a YAML playbook (scripted command sequence) and exit.
/// Requires `--features playbook`.
#[cfg(feature = "playbook")]
Run {
/// Path to the YAML playbook file.
playbook: PathBuf,
/// State file path (identity + MLS state).
#[arg(long, default_value = "qpq-state.bin", env = "QPQ_STATE")]
state: PathBuf,
/// Server address (host:port).
#[arg(long, default_value = "127.0.0.1:7000", env = "QPQ_SERVER")]
server: String,
/// OPAQUE username for automatic login.
#[arg(long, env = "QPQ_USERNAME")]
username: Option<String>,
/// OPAQUE password.
#[arg(long, env = "QPQ_PASSWORD")]
password: Option<String>,
/// Override playbook variables: KEY=VALUE (repeatable).
#[arg(long = "var", short = 'V')]
vars: Vec<String>,
},
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -410,6 +438,77 @@ fn derive_state_path(state: PathBuf, username: Option<&str>) -> PathBuf {
state
}
// ── Playbook execution ───────────────────────────────────────────────────────
#[cfg(feature = "playbook")]
async fn run_playbook(
playbook_path: &Path,
state: &Path,
server: &str,
ca_cert: &Path,
server_name: &str,
state_pw: Option<&str>,
username: Option<&str>,
password: Option<&str>,
access_token: &str,
device_id: Option<&str>,
extra_vars: &[String],
) -> anyhow::Result<()> {
use quicproquo_client::PlaybookRunner;
let insecure = std::env::var("QPQ_DANGER_ACCEPT_INVALID_CERTS").is_ok();
// Connect to server.
let client =
quicproquo_client::connect_node_opt(server, ca_cert, server_name, insecure)
.await
.context("connect to server")?;
// Build session state.
let mut session = quicproquo_client::client::session::SessionState::load(state, state_pw)
.context("load session state")?;
// If username/password provided, do OPAQUE login.
if let (Some(uname), Some(pw)) = (username, password) {
if let Err(e) =
quicproquo_client::opaque_login(&client, uname, pw, &session.identity.public_key_bytes()).await
{
eprintln!("OPAQUE login failed: {e:#}");
}
} else if !access_token.is_empty() {
let auth = ClientAuth::from_parts(access_token.to_string(), device_id.map(String::from));
init_auth(auth);
}
// Load playbook.
let mut runner = PlaybookRunner::from_file(playbook_path)
.with_context(|| format!("load playbook: {}", playbook_path.display()))?;
// Inject extra variables from --var KEY=VALUE flags.
for kv in extra_vars {
if let Some((k, v)) = kv.split_once('=') {
runner.set_var(k, v);
} else {
eprintln!("warning: ignoring malformed --var '{kv}' (expected KEY=VALUE)");
}
}
// Inject connection info as variables.
runner.set_var("_server", server);
if let Some(u) = username {
runner.set_var("_username", u);
}
let report = runner.run(&mut session, &client).await;
print!("{report}");
if report.all_passed() {
Ok(())
} else {
std::process::exit(1);
}
}
// ── Entry point ───────────────────────────────────────────────────────────────
#[tokio::main]
@@ -736,5 +835,32 @@ async fn main() -> anyhow::Result<()> {
)
}
Command::ExportVerify { input } => cmd_export_verify(&input),
#[cfg(feature = "playbook")]
Command::Run {
playbook,
state,
server,
username,
password,
vars,
} => {
let state = derive_state_path(state, username.as_deref());
let local = tokio::task::LocalSet::new();
local
.run_until(run_playbook(
&playbook,
&state,
&server,
&args.ca_cert,
&args.server_name,
state_pw,
username.as_deref(),
password.as_deref(),
&args.access_token,
args.device_id.as_deref(),
&vars,
))
.await
}
}
}