mirror of
https://github.com/Pumpkin-MC/Pumpkin.git
synced 2026-08-30 20:14:23 +00:00
feat: add config to enable/disable commands and set their permission level (#2799)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pumpkin_util::PermissionLvl;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -19,6 +21,26 @@ pub struct CommandsConfig {
|
||||
pub broadcast_console_to_ops: bool,
|
||||
/// The `op` permission level of everyone that is not in the `ops` file.
|
||||
pub default_op_level: PermissionLvl,
|
||||
/// Per-command settings, so you can turn individual commands off or change
|
||||
/// who is allowed to use them.
|
||||
///
|
||||
/// Each entry is named after the command it applies to (without the leading
|
||||
/// slash), for example `gamemode` or `tp`. You only need to list the
|
||||
/// commands you actually want to change; everything you leave out keeps its
|
||||
/// normal behaviour.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// ```toml
|
||||
/// # Only server owners may change gamemodes
|
||||
/// [commands.overrides.gamemode]
|
||||
/// permission_level = 4
|
||||
///
|
||||
/// # Turn the /tp command off completely
|
||||
/// [commands.overrides.tp]
|
||||
/// enabled = false
|
||||
/// ```
|
||||
pub overrides: HashMap<String, CommandOverride>,
|
||||
}
|
||||
|
||||
impl Default for CommandsConfig {
|
||||
@@ -29,6 +51,38 @@ impl Default for CommandsConfig {
|
||||
use_tty: true,
|
||||
broadcast_console_to_ops: true,
|
||||
default_op_level: PermissionLvl::Zero,
|
||||
overrides: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings for a single command, letting a server owner turn it off or change
|
||||
/// who is allowed to run it.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CommandOverride {
|
||||
/// Whether this command can be used at all. When set to `false` the command
|
||||
/// is hidden completely: it won't run, won't show up in the command list,
|
||||
/// and won't appear in tab-completion. Players who try to use it simply get
|
||||
/// the normal "unknown command" message. Set to `true` (the default) to
|
||||
/// leave the command available.
|
||||
pub enabled: bool,
|
||||
/// Who is allowed to use this command, given as a permission level:
|
||||
///
|
||||
/// - `0` = everyone can use it
|
||||
/// - `2` = normal operators (the usual level for most cheat-style commands)
|
||||
/// - `3` = admins (player management, kicking, banning, and so on)
|
||||
/// - `4` = the server owner only (full server management)
|
||||
///
|
||||
/// Leave this out to keep the command's normal requirement.
|
||||
pub permission_level: Option<PermissionLvl>,
|
||||
}
|
||||
|
||||
impl Default for CommandOverride {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
permission_level: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ pub mod recipe;
|
||||
pub mod resource_pack;
|
||||
|
||||
pub use chat::ChatConfig;
|
||||
pub use commands::CommandsConfig;
|
||||
pub use commands::{CommandOverride, CommandsConfig};
|
||||
pub use networking::auth::AuthenticationConfig;
|
||||
pub use networking::bedrock::BedrockConfig;
|
||||
pub use networking::compression::CompressionConfig;
|
||||
|
||||
@@ -125,6 +125,27 @@ impl PermissionRegistry {
|
||||
self.permissions.get(node)
|
||||
}
|
||||
|
||||
/// Overrides the default behaviour of an already-registered permission.
|
||||
///
|
||||
/// Used to apply per-command permission overrides from the server
|
||||
/// configuration after the built-in permissions have been registered.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `node`: The full permission node string to update.
|
||||
/// - `default`: The new default behaviour to apply.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `true` if the node existed and its default was updated.
|
||||
/// - `false` if no permission with that node is registered.
|
||||
pub fn set_default(&mut self, node: &str, default: PermissionDefault) -> bool {
|
||||
if let Some(permission) = self.permissions.get_mut(node) {
|
||||
permission.default = default;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether a permission node exists in the registry.
|
||||
///
|
||||
/// # Parameters
|
||||
|
||||
@@ -32,6 +32,10 @@ pub async fn send_c_commands_packet(
|
||||
|
||||
let fallback_dispatcher = &dispatcher.fallback_dispatcher;
|
||||
for key in fallback_dispatcher.commands.keys() {
|
||||
if dispatcher.is_disabled(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(tree) = fallback_dispatcher.get_tree(key) else {
|
||||
continue;
|
||||
};
|
||||
@@ -91,7 +95,30 @@ pub async fn send_c_commands_packet(
|
||||
|
||||
match node {
|
||||
AttachedNode::Root(_) => {
|
||||
root_node_children_second = children;
|
||||
// Drop disabled commands from the root's child list so they
|
||||
// disappear from the client's command graph (and tab-completion)
|
||||
// entirely. The nodes themselves stay in `proto_nodes` to keep
|
||||
// every other node's indices valid; they simply become
|
||||
// unreachable.
|
||||
root_node_children_second = node
|
||||
.children_ref()
|
||||
.values()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
let disabled = match &dispatcher.tree[*id] {
|
||||
AttachedNode::Literal(child) => {
|
||||
dispatcher.is_disabled(&child.meta.literal_lowercase)
|
||||
}
|
||||
AttachedNode::Command(child) => {
|
||||
dispatcher.is_disabled(&child.meta.literal_lowercase)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
!disabled
|
||||
})
|
||||
.map(|id| resolve_node_id(id, node_id_offset, root_node_index))
|
||||
.map(|i| i.try_into().expect("i32 limit reached for ids"))
|
||||
.collect();
|
||||
}
|
||||
AttachedNode::Literal(literal_attached_node) => {
|
||||
let node = ProtoNode {
|
||||
@@ -272,6 +299,10 @@ pub async fn send_bedrock_commands_packet(
|
||||
|
||||
let fallback_dispatcher = &dispatcher.fallback_dispatcher;
|
||||
for key in fallback_dispatcher.commands.keys() {
|
||||
if dispatcher.is_disabled(key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(tree) = fallback_dispatcher.get_tree(key) else {
|
||||
continue;
|
||||
};
|
||||
@@ -335,6 +366,10 @@ pub async fn send_bedrock_commands_packet(
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
if dispatcher.is_disabled(&name.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut ctx = BuilderContext {
|
||||
enum_values: &mut enum_values,
|
||||
enums: &mut enums,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use crate::command::node::dispatcher::CommandDispatcher;
|
||||
use pumpkin_config::BasicConfiguration;
|
||||
use crate::command::tree::Command;
|
||||
use pumpkin_config::{BasicConfiguration, CommandsConfig};
|
||||
use pumpkin_util::{
|
||||
PermissionLvl,
|
||||
permission::{Permission, PermissionDefault, PermissionRegistry},
|
||||
};
|
||||
use tokio::sync::RwLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
mod advancement;
|
||||
mod attribute;
|
||||
@@ -88,6 +90,7 @@ mod worldborder;
|
||||
pub async fn default_dispatcher(
|
||||
registry: &RwLock<PermissionRegistry>,
|
||||
_basic_config: &BasicConfiguration,
|
||||
commands_config: &CommandsConfig,
|
||||
) -> CommandDispatcher {
|
||||
let mut dispatcher = crate::command::dispatcher::CommandDispatcher::default();
|
||||
|
||||
@@ -206,9 +209,113 @@ pub async fn default_dispatcher(
|
||||
clone::register(&mut dispatcher, registry);
|
||||
attribute::register(&mut dispatcher, registry);
|
||||
fetchprofile::register(&mut dispatcher, registry);
|
||||
|
||||
apply_command_overrides(&mut dispatcher, registry, commands_config);
|
||||
|
||||
dispatcher
|
||||
}
|
||||
|
||||
/// Applies the per-command settings from the server configuration on top of the
|
||||
/// freshly built dispatcher.
|
||||
///
|
||||
/// Two kinds of override are supported:
|
||||
/// - Disabling a command, which removes it from the legacy dispatcher and marks
|
||||
/// its name so the wrapper dispatcher hides it everywhere else.
|
||||
/// - Changing a command's required permission level, which is done by rewriting
|
||||
/// the default of its permission node in the registry. Because command
|
||||
/// requirements look their permission up in the registry at execution time,
|
||||
/// this affects both the legacy and the node-based dispatchers uniformly.
|
||||
fn apply_command_overrides(
|
||||
dispatcher: &mut CommandDispatcher,
|
||||
registry: &mut PermissionRegistry,
|
||||
commands_config: &CommandsConfig,
|
||||
) {
|
||||
for (raw_name, settings) in &commands_config.overrides {
|
||||
// Command names are always lowercase, so normalise here to be forgiving
|
||||
// of how the owner wrote them in the config file.
|
||||
let name = raw_name.to_ascii_lowercase();
|
||||
|
||||
// Catch typos: an override for a command that does not exist almost
|
||||
// always means the owner misspelled it, so tell them instead of silently
|
||||
// doing nothing.
|
||||
if !dispatcher.has_command(&name) {
|
||||
warn!(
|
||||
"Ignoring the command setting for \"{raw_name}\" because there is no command with that name (check the spelling in your config)"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if !settings.enabled {
|
||||
// If the owner named an alias (e.g. `tp` for `teleport`), turn off
|
||||
// the whole command, not just that one alias.
|
||||
let primary = match dispatcher.fallback_dispatcher.commands.get(&name) {
|
||||
Some(Command::Alias(target)) => target.clone(),
|
||||
_ => name.clone(),
|
||||
};
|
||||
|
||||
dispatcher.disable_command(name.clone());
|
||||
dispatcher.disable_command(primary.clone());
|
||||
// Node-based commands keep their aliases as redirecting root nodes,
|
||||
// so flag those too. (Legacy aliases are handled by the unregister
|
||||
// below, which removes them from the dispatcher outright.)
|
||||
for alias in dispatcher.tree_alias_names(&primary) {
|
||||
dispatcher.disable_command(alias);
|
||||
}
|
||||
// Unregistering the primary name cascades to every alias in the
|
||||
// legacy dispatcher.
|
||||
dispatcher.fallback_dispatcher.unregister(&primary);
|
||||
info!("The /{primary} command has been turned off in the configuration");
|
||||
// A disabled command can never be run, so its permission level is
|
||||
// irrelevant; skip the rest.
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(level) = settings.permission_level {
|
||||
let default = if level == PermissionLvl::Zero {
|
||||
PermissionDefault::Allow
|
||||
} else {
|
||||
PermissionDefault::Op(level)
|
||||
};
|
||||
|
||||
if let Some(node) = resolve_permission_node(dispatcher, registry, &name) {
|
||||
registry.set_default(&node, default);
|
||||
info!(
|
||||
"The /{name} command now needs permission level {} to use",
|
||||
level as u8
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Command override for /{name} sets a permission level, but no matching permission node could be found; leaving it unchanged"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds the permission node associated with a command name.
|
||||
///
|
||||
/// Legacy commands record their node in the dispatcher directly. Node-based
|
||||
/// commands do not, but they follow the `<namespace>:command.<name>` convention,
|
||||
/// so we fall back to probing the registry for those.
|
||||
fn resolve_permission_node(
|
||||
dispatcher: &CommandDispatcher,
|
||||
registry: &PermissionRegistry,
|
||||
name: &str,
|
||||
) -> Option<String> {
|
||||
if let Some(node) = dispatcher.fallback_dispatcher.permissions.get(name) {
|
||||
return Some(node.clone());
|
||||
}
|
||||
|
||||
for namespace in ["minecraft", "pumpkin"] {
|
||||
let candidate = format!("{namespace}:command.{name}");
|
||||
if registry.get_permission(&candidate).is_some() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn register_permissions(registry: &mut PermissionRegistry) {
|
||||
// Register level 0 permissions (allowed by default)
|
||||
register_level_0_permissions(registry);
|
||||
@@ -549,3 +656,172 @@ fn register_level_3_permissions(registry: &mut PermissionRegistry) {
|
||||
))
|
||||
.expect("Permission already registered");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod override_tests {
|
||||
use pumpkin_config::{BasicConfiguration, CommandOverride, CommandsConfig};
|
||||
use pumpkin_util::PermissionLvl;
|
||||
use pumpkin_util::permission::{PermissionDefault, PermissionRegistry};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use super::default_dispatcher;
|
||||
|
||||
fn disabled(config: &mut CommandsConfig, name: &str) {
|
||||
config.overrides.insert(
|
||||
name.to_string(),
|
||||
CommandOverride {
|
||||
enabled: false,
|
||||
permission_level: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn permission(config: &mut CommandsConfig, name: &str, level: PermissionLvl) {
|
||||
config.overrides.insert(
|
||||
name.to_string(),
|
||||
CommandOverride {
|
||||
enabled: true,
|
||||
permission_level: Some(level),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabling_a_command_removes_and_hides_it() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
// `gamemode` lives on the legacy dispatcher; disabling it should remove
|
||||
// it there and flag it on the wrapper.
|
||||
disabled(&mut commands, "gamemode");
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
assert!(dispatcher.is_disabled("gamemode"));
|
||||
assert!(
|
||||
dispatcher.fallback_dispatcher.get_tree("gamemode").is_err(),
|
||||
"disabled command should be unregistered from the legacy dispatcher"
|
||||
);
|
||||
assert!(
|
||||
!dispatcher.is_disabled("give"),
|
||||
"untouched commands stay on"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabling_an_alias_turns_off_the_whole_command() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
// `tp` is an alias of `teleport`; disabling it should take the whole
|
||||
// command down, including the primary name.
|
||||
disabled(&mut commands, "tp");
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
assert!(dispatcher.is_disabled("tp"));
|
||||
assert!(dispatcher.is_disabled("teleport"));
|
||||
assert!(dispatcher.fallback_dispatcher.get_tree("tp").is_err());
|
||||
assert!(dispatcher.fallback_dispatcher.get_tree("teleport").is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabling_a_node_command_also_disables_its_aliases() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
// `help` is a node-based command with the aliases `h` and `?`.
|
||||
disabled(&mut commands, "help");
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
assert!(dispatcher.is_disabled("help"));
|
||||
assert!(dispatcher.is_disabled("h"));
|
||||
assert!(dispatcher.is_disabled("?"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn override_for_unknown_command_is_ignored() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
// A command name that does not exist (usually a typo in the config)
|
||||
// should be ignored, not silently swallow a real command or panic.
|
||||
disabled(&mut commands, "notacommand");
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
assert!(!dispatcher.is_disabled("notacommand"));
|
||||
assert!(dispatcher.fallback_dispatcher.get_tree("gamemode").is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn override_is_case_insensitive() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
disabled(&mut commands, "GameMode");
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
assert!(dispatcher.is_disabled("gamemode"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_level_override_rewrites_the_registry_default() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
// `gamemode` is normally level 2; bump it to owner-only.
|
||||
permission(&mut commands, "gamemode", PermissionLvl::Four);
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let _dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
let registry = registry.read().await;
|
||||
let permission = registry
|
||||
.get_permission("minecraft:command.gamemode")
|
||||
.expect("gamemode permission should be registered");
|
||||
assert_eq!(
|
||||
permission.default,
|
||||
PermissionDefault::Op(PermissionLvl::Four)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_override_resolves_node_command_by_convention() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
// `kill` is a node-based command whose permission node is not recorded in
|
||||
// the legacy dispatcher, so the override must fall back to the
|
||||
// `minecraft:command.kill` naming convention.
|
||||
permission(&mut commands, "kill", PermissionLvl::Four);
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let _dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
let registry = registry.read().await;
|
||||
let permission = registry
|
||||
.get_permission("minecraft:command.kill")
|
||||
.expect("kill permission should be registered");
|
||||
assert_eq!(
|
||||
permission.default,
|
||||
PermissionDefault::Op(PermissionLvl::Four)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_level_zero_allows_everyone() {
|
||||
let basic = BasicConfiguration::default();
|
||||
let mut commands = CommandsConfig::default();
|
||||
permission(&mut commands, "gamemode", PermissionLvl::Zero);
|
||||
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let _dispatcher = default_dispatcher(®istry, &basic, &commands).await;
|
||||
|
||||
let registry = registry.read().await;
|
||||
let permission = registry
|
||||
.get_permission("minecraft:command.gamemode")
|
||||
.expect("gamemode permission should be registered");
|
||||
assert_eq!(permission.default, PermissionDefault::Allow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,8 +727,9 @@ mod test {
|
||||
#[tokio::test]
|
||||
async fn dynamic_command() {
|
||||
let config = BasicConfiguration::default();
|
||||
let commands_config = pumpkin_config::CommandsConfig::default();
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let mut dispatcher = default_dispatcher(®istry, &config)
|
||||
let mut dispatcher = default_dispatcher(®istry, &config, &commands_config)
|
||||
.await
|
||||
.fallback_dispatcher;
|
||||
let tree = CommandTree::new(["test"], "test_desc");
|
||||
@@ -738,8 +739,9 @@ mod test {
|
||||
#[tokio::test]
|
||||
async fn pumpkin_command_aliases() {
|
||||
let config = BasicConfiguration::default();
|
||||
let commands_config = pumpkin_config::CommandsConfig::default();
|
||||
let registry = RwLock::new(PermissionRegistry::new());
|
||||
let dispatcher = default_dispatcher(®istry, &config)
|
||||
let dispatcher = default_dispatcher(®istry, &config, &commands_config)
|
||||
.await
|
||||
.fallback_dispatcher;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use pumpkin_protocol::java::client::play::CommandSuggestion;
|
||||
use pumpkin_util::text::TextComponent;
|
||||
use pumpkin_util::text::click::ClickEvent;
|
||||
use pumpkin_util::text::color::{Color, NamedColor};
|
||||
use rustc_hash::FxHashMap;
|
||||
use rustc_hash::{FxHashMap, FxHashSet};
|
||||
use std::collections::BTreeMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, LazyLock};
|
||||
@@ -103,6 +103,11 @@ pub struct CommandDispatcher {
|
||||
// We add this because we have a lot of commands
|
||||
// still dependent on this dispatcher.
|
||||
pub fallback_dispatcher: crate::command::dispatcher::CommandDispatcher,
|
||||
|
||||
/// Primary names of commands that have been turned off through the server
|
||||
/// configuration. A disabled command behaves as if it does not exist: it
|
||||
/// cannot be executed and is left out of listings and suggestions.
|
||||
disabled: FxHashSet<String>,
|
||||
}
|
||||
|
||||
impl Default for CommandDispatcher {
|
||||
@@ -124,9 +129,70 @@ impl CommandDispatcher {
|
||||
tree,
|
||||
consumer: RESULT_DEFERRER.clone(),
|
||||
fallback_dispatcher: crate::command::dispatcher::CommandDispatcher::default(),
|
||||
disabled: FxHashSet::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a command off. A disabled command's primary name is recorded here
|
||||
/// so that it can no longer be executed, listed, or suggested, regardless of
|
||||
/// which internal dispatcher it lives on.
|
||||
pub fn disable_command(&mut self, name: impl Into<String>) {
|
||||
self.disabled.insert(name.into());
|
||||
}
|
||||
|
||||
/// Returns `true` if the command with the given primary name has been turned
|
||||
/// off through the server configuration.
|
||||
#[must_use]
|
||||
pub fn is_disabled(&self, name: &str) -> bool {
|
||||
self.disabled.contains(name)
|
||||
}
|
||||
|
||||
/// Returns `true` if a command (or alias) with the given name is registered
|
||||
/// on either the node-based tree or the legacy dispatcher.
|
||||
#[must_use]
|
||||
pub fn has_command(&self, name: &str) -> bool {
|
||||
self.tree.get(name).is_some() || self.fallback_dispatcher.commands.contains_key(name)
|
||||
}
|
||||
|
||||
/// Collects the names of every root-level alias that redirects to the command
|
||||
/// with the given primary name.
|
||||
///
|
||||
/// Node-based commands model their aliases as extra root literals that
|
||||
/// redirect to the primary node (see `register_with_aliases`). When a command
|
||||
/// is disabled we also need to turn those aliases off, otherwise a player
|
||||
/// could still reach the live executor through one of them.
|
||||
#[must_use]
|
||||
pub fn tree_alias_names(&self, primary: &str) -> Vec<String> {
|
||||
let Some(primary_id) = self.tree.get(primary) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let primary_node: NodeId = primary_id.into();
|
||||
|
||||
let mut names = Vec::new();
|
||||
for child in self.tree.get_root_children() {
|
||||
// Index by `NodeId` so we get the `AttachedNode` enum (which exposes
|
||||
// `redirect`/`name`) rather than the inner command node.
|
||||
let node_id: NodeId = child.into();
|
||||
let node = &self.tree[node_id];
|
||||
if let Some(redirect) = node.redirect()
|
||||
&& self.tree.resolve(redirect) == Some(primary_node)
|
||||
{
|
||||
names.push(node.name().to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Extracts the command name (the first whitespace-separated token) from a
|
||||
/// raw input string, ignoring any leading slash.
|
||||
fn command_name(input: &str) -> &str {
|
||||
input
|
||||
.trim_start_matches('/')
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// Registers a command which can then be dispatched.
|
||||
/// Returns the local ID of the node attached to the tree.
|
||||
///
|
||||
@@ -204,6 +270,15 @@ impl CommandDispatcher {
|
||||
source: &CommandSource,
|
||||
) -> Result<i32, CommandSyntaxError> {
|
||||
let mut reader = StringReader::new(input);
|
||||
|
||||
// A disabled command must behave as if it does not exist from every
|
||||
// execution path, not just `handle_command`. This backstop covers
|
||||
// programmatic callers such as `/execute run <command>`, which reach the
|
||||
// dispatcher here without going through `handle_command`.
|
||||
if self.is_disabled(Self::command_name(input)) {
|
||||
return Err(DISPATCHER_UNKNOWN_COMMAND.create(&reader));
|
||||
}
|
||||
|
||||
self.execute_reader(&mut reader, source).await
|
||||
}
|
||||
|
||||
@@ -386,6 +461,16 @@ impl CommandDispatcher {
|
||||
input = sliced;
|
||||
}
|
||||
|
||||
// A command that has been turned off in the configuration must behave as
|
||||
// if it does not exist, so we report the usual "unknown command" error
|
||||
// before either dispatcher gets a chance to run it.
|
||||
if self.is_disabled(Self::command_name(input)) {
|
||||
let reader = StringReader::new(input);
|
||||
Self::send_error_to_source(source, DISPATCHER_UNKNOWN_COMMAND.create(&reader), input)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
let output = self.execute_input(input, source).await;
|
||||
|
||||
if let Err(error) = output {
|
||||
@@ -562,6 +647,11 @@ impl CommandDispatcher {
|
||||
/// This function currently panics if the source provided was a dummy source.
|
||||
/// This is subject to change in the future.
|
||||
pub async fn suggest(&self, input: &str, source: &CommandSource) -> Vec<CommandSuggestion> {
|
||||
// Never suggest arguments for a command that has been turned off.
|
||||
if self.is_disabled(Self::command_name(input)) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let future1 = async move {
|
||||
let parsed = self.parse_input(input, source).await;
|
||||
let suggestions = self.get_completion_suggestions_at_end(parsed).await;
|
||||
@@ -596,11 +686,17 @@ impl CommandDispatcher {
|
||||
|
||||
for command in self.tree.get_root_children() {
|
||||
let meta = &self.tree[command].meta;
|
||||
if self.is_disabled(&meta.literal_lowercase) {
|
||||
continue;
|
||||
}
|
||||
commands.insert(&meta.literal_lowercase, &meta.description);
|
||||
}
|
||||
|
||||
for fallback_command in self.fallback_dispatcher.commands.values() {
|
||||
if let Command::Tree(command_tree) = fallback_command {
|
||||
if self.is_disabled(&command_tree.names[0]) {
|
||||
continue;
|
||||
}
|
||||
for name in &command_tree.names {
|
||||
commands.insert(name, &command_tree.description);
|
||||
}
|
||||
@@ -621,12 +717,18 @@ impl CommandDispatcher {
|
||||
for command in self.tree.get_root_children() {
|
||||
if self.tree.can_use(command.into(), source).await {
|
||||
let meta = &self.tree[command].meta;
|
||||
if self.is_disabled(&meta.literal_lowercase) {
|
||||
continue;
|
||||
}
|
||||
commands.insert(&meta.literal_lowercase, &meta.description);
|
||||
}
|
||||
}
|
||||
|
||||
for fallback_command in self.fallback_dispatcher.commands.values() {
|
||||
if let Command::Tree(command_tree) = fallback_command {
|
||||
if self.is_disabled(&command_tree.names[0]) {
|
||||
continue;
|
||||
}
|
||||
if let Some(permission) = self
|
||||
.fallback_dispatcher
|
||||
.permissions
|
||||
@@ -662,12 +764,16 @@ impl CommandDispatcher {
|
||||
for (command_node_id, usage) in self.get_usage_of_commands(source).await {
|
||||
let meta = &self.tree[command_node_id].meta;
|
||||
let command_name = meta.literal.as_ref();
|
||||
if self.is_disabled(&meta.literal_lowercase) {
|
||||
continue;
|
||||
}
|
||||
let command_description = meta.description.as_ref();
|
||||
commands.insert(command_name, (command_description, usage.into_boxed_str()));
|
||||
}
|
||||
|
||||
for fallback_command in self.fallback_dispatcher.commands.values() {
|
||||
if let Command::Tree(command_tree) = fallback_command
|
||||
&& !self.is_disabled(&command_tree.names[0])
|
||||
&& let Some(permission) = self
|
||||
.fallback_dispatcher
|
||||
.permissions
|
||||
@@ -977,6 +1083,23 @@ mod test {
|
||||
assert_eq!(result, Ok(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_command_cannot_be_executed_directly() {
|
||||
// Guards the `/execute run <command>` bypass: a disabled command must be
|
||||
// rejected even when reached through `execute_input` rather than
|
||||
// `handle_command`.
|
||||
let mut dispatcher = CommandDispatcher::new();
|
||||
let executor: for<'c> fn(&'c CommandContext) -> CommandExecutorResult<'c> =
|
||||
|_| Box::pin(async move { Ok(1) });
|
||||
dispatcher
|
||||
.register(CommandArgumentBuilder::new("simple", "A simple command").executes(executor));
|
||||
dispatcher.disable_command("simple");
|
||||
|
||||
let source = CommandSource::dummy();
|
||||
let result = dispatcher.execute_input("simple", &source).await;
|
||||
assert!(result.is_err_and(|error| error.error_type == &DISPATCHER_UNKNOWN_COMMAND));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn arithmetic_command() {
|
||||
enum Operation {
|
||||
|
||||
@@ -154,8 +154,14 @@ impl Server {
|
||||
) -> Arc<Self> {
|
||||
let permission_registry = Arc::new(RwLock::new(PermissionRegistry::new()));
|
||||
// First register the default commands. After that, plugins can put in their own.
|
||||
let command_dispatcher =
|
||||
RwLock::new(default_dispatcher(&permission_registry, &basic_config).await);
|
||||
let command_dispatcher = RwLock::new(
|
||||
default_dispatcher(
|
||||
&permission_registry,
|
||||
&basic_config,
|
||||
&advanced_config.commands,
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
crate::command::set_broadcast_console_to_ops(
|
||||
advanced_config.commands.broadcast_console_to_ops,
|
||||
|
||||
Reference in New Issue
Block a user