From 55312adfa152deec64770ec0f65a984497f5f283 Mon Sep 17 00:00:00 2001 From: Dark Star Date: Wed, 12 Aug 2026 21:47:40 +0700 Subject: [PATCH] feat: add config to enable/disable commands and set their permission level (#2799) --- crates/pumpkin-config/src/commands.rs | 54 ++++ crates/pumpkin-config/src/lib.rs | 2 +- crates/pumpkin-util/src/permission.rs | 21 ++ .../pumpkin/src/command/client_suggestions.rs | 37 ++- crates/pumpkin/src/command/commands/mod.rs | 278 +++++++++++++++++- crates/pumpkin/src/command/dispatcher.rs | 6 +- crates/pumpkin/src/command/node/dispatcher.rs | 125 +++++++- crates/pumpkin/src/server/mod.rs | 10 +- 8 files changed, 525 insertions(+), 8 deletions(-) diff --git a/crates/pumpkin-config/src/commands.rs b/crates/pumpkin-config/src/commands.rs index 7f3f2c5a6..85093bd25 100644 --- a/crates/pumpkin-config/src/commands.rs +++ b/crates/pumpkin-config/src/commands.rs @@ -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, } 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, +} + +impl Default for CommandOverride { + fn default() -> Self { + Self { + enabled: true, + permission_level: None, } } } diff --git a/crates/pumpkin-config/src/lib.rs b/crates/pumpkin-config/src/lib.rs index 1f5d073f0..7f260236f 100644 --- a/crates/pumpkin-config/src/lib.rs +++ b/crates/pumpkin-config/src/lib.rs @@ -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; diff --git a/crates/pumpkin-util/src/permission.rs b/crates/pumpkin-util/src/permission.rs index abc42fe6a..a4fea2afd 100644 --- a/crates/pumpkin-util/src/permission.rs +++ b/crates/pumpkin-util/src/permission.rs @@ -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 diff --git a/crates/pumpkin/src/command/client_suggestions.rs b/crates/pumpkin/src/command/client_suggestions.rs index 9e31c2e77..3f492d031 100644 --- a/crates/pumpkin/src/command/client_suggestions.rs +++ b/crates/pumpkin/src/command/client_suggestions.rs @@ -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, diff --git a/crates/pumpkin/src/command/commands/mod.rs b/crates/pumpkin/src/command/commands/mod.rs index d2297112e..1cab8fc78 100644 --- a/crates/pumpkin/src/command/commands/mod.rs +++ b/crates/pumpkin/src/command/commands/mod.rs @@ -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, _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 `:command.` convention, +/// so we fall back to probing the registry for those. +fn resolve_permission_node( + dispatcher: &CommandDispatcher, + registry: &PermissionRegistry, + name: &str, +) -> Option { + 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); + } +} diff --git a/crates/pumpkin/src/command/dispatcher.rs b/crates/pumpkin/src/command/dispatcher.rs index c6a20e0f0..c69e458a6 100644 --- a/crates/pumpkin/src/command/dispatcher.rs +++ b/crates/pumpkin/src/command/dispatcher.rs @@ -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; diff --git a/crates/pumpkin/src/command/node/dispatcher.rs b/crates/pumpkin/src/command/node/dispatcher.rs index dda8d6504..487c809d4 100644 --- a/crates/pumpkin/src/command/node/dispatcher.rs +++ b/crates/pumpkin/src/command/node/dispatcher.rs @@ -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, } 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) { + 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 { + 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 { 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 `, 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 { + // 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 ` 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 { diff --git a/crates/pumpkin/src/server/mod.rs b/crates/pumpkin/src/server/mod.rs index b47e5374d..4e6a3ff8a 100644 --- a/crates/pumpkin/src/server/mod.rs +++ b/crates/pumpkin/src/server/mod.rs @@ -154,8 +154,14 @@ impl Server { ) -> Arc { 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,