Skip to content

Commit 1e6052c

Browse files
{Core} Case-insensitive lookup of command in command_index (#32945)
1 parent 42330bc commit 1e6052c

8 files changed

Lines changed: 15 additions & 122 deletions

File tree

src/azure-cli-core/azure/cli/core/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,8 @@ def get(self, args):
792792
return None
793793

794794
# Get the top-level command, like `network` in `network vnet create -h`
795-
top_command = args[0]
795+
# Normalize top-level command for index lookup so mixed-case commands hit key
796+
top_command = args[0].lower()
796797
index = self.INDEX[self._COMMAND_INDEX]
797798
# Check the command index for (command: [module]) mapping, like
798799
# "network": ["azure.cli.command_modules.natgateway", "azure.cli.command_modules.network", "azext_firewall"]

src/azure-cli-core/azure/cli/core/commands/__init__.py

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -512,14 +512,11 @@ def execute(self, args):
512512
EVENT_INVOKER_FILTER_RESULT)
513513
from azure.cli.core.commands.events import (
514514
EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, EVENT_INVOKER_PRE_LOAD_ARGUMENTS, EVENT_INVOKER_POST_LOAD_ARGUMENTS)
515-
from azure.cli.core.util import roughly_parse_command_with_casing
516515

517-
# TODO: Can't simply be invoked as an event because args are transformed
518-
command_preserve_casing = roughly_parse_command_with_casing(args)
519516
args = _pre_command_table_create(self.cli_ctx, args)
520517

521518
if self._should_show_cached_help(args):
522-
result = self._try_show_cached_help(command_preserve_casing, args)
519+
result = self._try_show_cached_help(args)
523520
if result:
524521
return result
525522

@@ -593,7 +590,7 @@ def execute(self, args):
593590
logger.debug("Failed to cache help data: %s", ex)
594591

595592
# TODO: No event in base with which to target
596-
telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing)
593+
telemetry.set_command_details('az')
597594
telemetry.set_success(summary='welcome')
598595
return CommandResultItem(None, exit_code=0)
599596

@@ -648,8 +645,7 @@ def execute(self, args):
648645
pass
649646
telemetry.set_command_details(self.cli_ctx.data['command'], self.data['output'],
650647
self.cli_ctx.data['safe_params'],
651-
extension_name=extension_name, extension_version=extension_version,
652-
command_preserve_casing=command_preserve_casing)
648+
extension_name=extension_name, extension_version=extension_version)
653649
if extension_name:
654650
self.data['command_extension_name'] = extension_name
655651
self.cli_ctx.logging.log_cmd_metadata_extension_info(extension_name, extension_version)
@@ -740,7 +736,7 @@ def _should_show_cached_help(self, args):
740736
self._is_top_level_help_request(args) and
741737
not self.cli_ctx.data.get('completer_active'))
742738

743-
def _try_show_cached_help(self, command_preserve_casing, args):
739+
def _try_show_cached_help(self, args):
744740
"""Try to show cached help for top-level help request.
745741
746742
Returns CommandResultItem if cached help was shown, None otherwise.
@@ -752,7 +748,7 @@ def _try_show_cached_help(self, command_preserve_casing, args):
752748
if help_index:
753749
# Display cached help using the help system
754750
self.help.show_cached_help(help_index, args)
755-
telemetry.set_command_details('az', command_preserve_casing=command_preserve_casing, parameters=['--help'])
751+
telemetry.set_command_details('az', parameters=['--help'])
756752
telemetry.set_success(summary='show help')
757753
return CommandResultItem(None, exit_code=0)
758754

src/azure-cli-core/azure/cli/core/extension/dynamic_install.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,6 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis
194194
# extension is already installed and return if yes as the error is not caused by extension not installed.
195195
from azure.cli.core.extension import get_extension, ExtensionNotInstalledException
196196
from azure.cli.core.extension._resolve import resolve_from_index, NoExtensionCandidatesError
197-
from azure.cli.core.util import roughly_parse_command_with_casing
198197
extension_allow_preview = _get_extension_allow_preview_install_config(cli_ctx)
199198
try:
200199
ext = get_extension(ext_name)
@@ -209,8 +208,7 @@ def _check_value_in_extensions(cli_ctx, parser, args, no_prompt): # pylint: dis
209208

210209
telemetry.set_command_details(command_str,
211210
parameters=AzCliCommandInvoker._extract_parameter_names(args), # pylint: disable=protected-access
212-
extension_name=ext_name,
213-
command_preserve_casing=roughly_parse_command_with_casing(args))
211+
extension_name=ext_name)
214212
run_after_extension_installed = _get_extension_run_after_dynamic_install_config(cli_ctx)
215213
prompt_info = ""
216214
if no_prompt:

src/azure-cli-core/azure/cli/core/telemetry.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ def __init__(self, correlation_id=None, application=None):
5151
self.feedback = None
5252
self.extension_management_detail = None
5353
self.raw_command = None
54-
self.command_preserve_casing = None
5554
self.is_cmd_idx_rebuild_triggered = False
5655
self.show_survey_message = False
5756
self.region_input = None
@@ -209,8 +208,6 @@ def _get_azure_cli_properties(self):
209208
set_custom_properties(result, 'InvokeTimeElapsed', str(self.invoke_time_elapsed))
210209
set_custom_properties(result, 'OutputType', self.output_type)
211210
set_custom_properties(result, 'RawCommand', self.raw_command)
212-
set_custom_properties(result, 'CommandPreserveCasing',
213-
self.command_preserve_casing or '')
214211
set_custom_properties(result, 'IsCmdIdxRebuildTriggered', str(self.is_cmd_idx_rebuild_triggered))
215212
set_custom_properties(result, 'Params', ','.join(self.parameters or []))
216213
set_custom_properties(result, 'PythonVersion', platform.python_version())
@@ -448,13 +445,12 @@ def set_command_index_rebuild_triggered(is_cmd_idx_rebuild_triggered=False):
448445

449446
@decorators.suppress_all_exceptions()
450447
def set_command_details(command, output_type=None, parameters=None, extension_name=None,
451-
extension_version=None, command_preserve_casing=None):
448+
extension_version=None):
452449
_session.command = command
453450
_session.output_type = output_type
454451
_session.parameters = parameters
455452
_session.extension_name = extension_name
456453
_session.extension_version = extension_version
457-
_session.command_preserve_casing = command_preserve_casing
458454

459455

460456
@decorators.suppress_all_exceptions()

src/azure-cli-core/azure/cli/core/tests/test_command_registration.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,11 @@ def update_and_check_index():
371371
cmd_tbl = loader.load_command_table(["hello", "mod-only"])
372372
self.assertEqual(['hello mod-only', 'hello overridden', 'hello ext-only'], list(cmd_tbl.keys()))
373373

374+
# Test mixed-case top-level command still uses command index
375+
_set_index(self.expected_command_index)
376+
cmd_tbl = loader.load_command_table(["HELLO", "mod-only"])
377+
self.assertEqual(['hello mod-only', 'hello overridden', 'hello ext-only'], list(cmd_tbl.keys()))
378+
374379
# Full scenario test 1: Installing an extension 'azext_hello1' that extends 'hello' group
375380
outdated_command_index = {'hello': ['azure.cli.command_modules.hello'],
376381
'extra': ['azure.cli.command_modules.extra']}

src/azure-cli-core/azure/cli/core/tests/test_telemetry.py

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -80,41 +80,3 @@ def test_show_version_sets_telemetry_params(self, mock_get_version):
8080
self.assertEqual(session.parameters, ["--version"])
8181
self.assertIsNone(session.raw_command)
8282

83-
@mock.patch('azure.cli.core.util.get_az_version_string')
84-
def test_command_preserve_casing_telemetry(self, mock_get_version):
85-
"""Test telemetry captures command preserve casing during actual command invocation."""
86-
from azure.cli.core import telemetry
87-
from azure.cli.core.mock import DummyCli
88-
from knack.completion import ARGCOMPLETE_ENV_NAME
89-
90-
mock_get_version.return_value = ("azure-cli 2.80.0", ["core", "extension1"])
91-
92-
test_cases = [
93-
(["version"], "version"),
94-
(["VERSION"], "VERSION"),
95-
(["vm", "list"], "vm list"),
96-
(["Vm", "List"], "Vm List"),
97-
]
98-
99-
for command_args, expected_casing in test_cases:
100-
with self.subTest(command_args=command_args):
101-
cli = DummyCli()
102-
telemetry.set_application(cli, ARGCOMPLETE_ENV_NAME)
103-
telemetry.start()
104-
105-
try:
106-
cli.invoke(command_args)
107-
except SystemExit:
108-
pass
109-
except Exception:
110-
pass
111-
112-
# Verify the telemetry session preserves casing
113-
session = telemetry._session
114-
self.assertEqual(session.command_preserve_casing, expected_casing)
115-
116-
azure_cli_props = session._get_azure_cli_properties()
117-
118-
self.assertIn('Context.Default.AzureCLI.CommandPreserveCasing', azure_cli_props)
119-
self.assertEqual(azure_cli_props['Context.Default.AzureCLI.CommandPreserveCasing'],
120-
expected_casing)

src/azure-cli-core/azure/cli/core/tests/test_util.py

Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
(get_file_json, truncate_text, shell_safe_json_parse, b64_to_hex, hash_string, random_string,
1818
open_page_in_browser, can_launch_browser, handle_exception, ConfiguredDefaultSetter, send_raw_request,
1919
should_disable_connection_verify, parse_proxy_resource_id, get_az_user_agent, get_az_rest_user_agent,
20-
_get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command, roughly_parse_command_with_casing)
20+
_get_parent_proc_name, is_wsl, run_cmd, run_az_cmd, roughly_parse_command)
2121
from azure.cli.core.mock import DummyCli
2222

2323

@@ -638,57 +638,5 @@ def test_roughly_parse_command(self):
638638
self.assertEqual(roughly_parse_command(['--help']), '') # Starts with flag
639639
self.assertEqual(roughly_parse_command(['-h']), '') # Starts with short flag
640640

641-
def test_roughly_parse_command_with_casing(self):
642-
"""Test roughly_parse_command_with_casing function that preserves original casing"""
643-
# Basic command parsing with case preservation
644-
self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create']), 'az vm create')
645-
self.assertEqual(roughly_parse_command_with_casing(['account', 'show']), 'account show')
646-
self.assertEqual(roughly_parse_command_with_casing(['network', 'vnet', 'list']), 'network vnet list')
647-
648-
# Test case preservation - should keep original casing
649-
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'CREATE']), 'az VM CREATE')
650-
self.assertEqual(roughly_parse_command_with_casing(['Account', 'Show']), 'Account Show')
651-
self.assertEqual(roughly_parse_command_with_casing(['Az', 'Network', 'Vnet', 'List']), 'Az Network Vnet List')
652-
653-
# Test with flags - should stop at first flag and not include sensitive flag values
654-
self.assertEqual(roughly_parse_command_with_casing(['az', 'vm', 'create', '--name', 'secretVM']), 'az vm create')
655-
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'Create', '--name', 'superSecretVM']), 'az VM Create')
656-
self.assertEqual(roughly_parse_command_with_casing(['az', 'storage', 'account', 'create', '--name', 'mystorageaccount']), 'az storage account create')
657-
self.assertEqual(roughly_parse_command_with_casing(['az', 'keyvault', 'create', '--resource-group', 'myRG', '--name', 'myVault']), 'az keyvault create')
658-
659-
# Test with short flags
660-
self.assertEqual(roughly_parse_command_with_casing(['az', 'VM', 'list', '-g', 'myResourceGroup']), 'az VM list')
661-
self.assertEqual(roughly_parse_command_with_casing(['az', 'Group', 'create', '-n', 'myGroup', '-l', 'eastus']), 'az Group create')
662-
663-
# Test mixed case scenarios that might reveal user typing patterns
664-
self.assertEqual(roughly_parse_command_with_casing(['Az', 'Vm', 'Create']), 'Az Vm Create')
665-
self.assertEqual(roughly_parse_command_with_casing(['AZ', 'STORAGE', 'BLOB', 'LIST']), 'AZ STORAGE BLOB LIST')
666-
667-
# Edge cases
668-
self.assertEqual(roughly_parse_command_with_casing([]), '')
669-
self.assertEqual(roughly_parse_command_with_casing(['az']), 'az')
670-
self.assertEqual(roughly_parse_command_with_casing(['Az']), 'Az')
671-
self.assertEqual(roughly_parse_command_with_casing(['--help']), '') # Starts with flag
672-
self.assertEqual(roughly_parse_command_with_casing(['-h']), '') # Starts with short flag
673-
674-
# Security test - ensure no sensitive information leaks after flags
675-
test_cases_with_secrets = [
676-
(['az', 'vm', 'create', '--admin-password', 'SuperSecret123!'], 'az vm create'),
677-
(['az', 'sql', 'server', 'create', '--admin-user', 'admin', '--admin-password', 'VerySecret!'], 'az sql server create'),
678-
(['az', 'storage', 'account', 'create', '--name', 'storageacct', '--access-tier', 'Hot'], 'az storage account create'),
679-
(['Az', 'KeyVault', 'Secret', 'Set', '--vault-name', 'myVault', '--name', 'secretName', '--value', 'topSecret'], 'Az KeyVault Secret Set')
680-
]
681-
682-
for args, expected in test_cases_with_secrets:
683-
with self.subTest(args=args):
684-
result = roughly_parse_command_with_casing(args)
685-
self.assertEqual(result, expected)
686-
# Ensure no sensitive values made it through
687-
self.assertNotIn('SuperSecret123!', result)
688-
self.assertNotIn('VerySecret!', result)
689-
self.assertNotIn('topSecret', result)
690-
self.assertNotIn('storageacct', result) # Even non-secret values after flags should not appear
691-
692-
693641
if __name__ == '__main__':
694642
unittest.main()

src/azure-cli-core/azure/cli/core/util.py

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,19 +1317,6 @@ def roughly_parse_command(args):
13171317
return ' '.join(nouns).lower()
13181318

13191319

1320-
def roughly_parse_command_with_casing(args):
1321-
# Roughly parse the command part: <az VM create> --name vm1
1322-
# Similar to knack.invocation.CommandInvoker._rudimentary_get_command, but preserves original casing
1323-
# and we don't need to bother with positional args
1324-
nouns = []
1325-
for arg in args:
1326-
if arg and arg[0] != '-':
1327-
nouns.append(arg)
1328-
else:
1329-
break
1330-
return ' '.join(nouns)
1331-
1332-
13331320
def is_guid(guid):
13341321
import uuid
13351322
try:

0 commit comments

Comments
 (0)