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)
509 lines
22 KiB
Rust
509 lines
22 KiB
Rust
//! 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
|
|
}
|
|
}
|
|
}
|
|
|