diff --git a/mycli/main.py b/mycli/main.py index ae6ca3c5..04ba278a 100755 --- a/mycli/main.py +++ b/mycli/main.py @@ -71,7 +71,7 @@ from mycli.main_modes.list_dsn import main_list_dsn from mycli.main_modes.list_ssh_config import main_list_ssh_config from mycli.main_modes.repl import get_prompt, main_repl, set_all_external_titles -from mycli.packages import special +from mycli.packages import cli_utils, special from mycli.packages.cli_utils import filtered_sys_argv, is_valid_connection_scheme from mycli.packages.filepaths import dir_path_exists, guess_socket_location from mycli.packages.interactive_utils import confirm_destructive_query @@ -1490,6 +1490,11 @@ def get_password_from_file(password_file: str | None) -> str | None: click.secho(f"Error reading password file '{password_file}': {str(e)}", err=True, fg="red") sys.exit(1) + # pick up a password that was extracted from argv before Click parsing + # (passwords starting with '-' can't survive Click's option parsing) + if cli_args.password is None and cli_utils._extracted_password is not None: + cli_args.password = cli_utils._extracted_password + # if the password value looks like a DSN, treat it as such and # prompt for password if cli_args.database is None and isinstance(cli_args.password, str) and "://" in cli_args.password: diff --git a/mycli/packages/cli_utils.py b/mycli/packages/cli_utils.py index 65950130..66b6141e 100644 --- a/mycli/packages/cli_utils.py +++ b/mycli/packages/cli_utils.py @@ -2,12 +2,70 @@ import sys +# Stash for a password extracted from argv before Click parsing. +# Click cannot handle passwords that start with a dash (e.g. --password -mypass) +# because it interprets them as option flags. _normalize_password_args strips +# such values from argv and stores them here so click_entrypoint can pick them up. +_extracted_password: str | None = None + def filtered_sys_argv() -> list[str]: args = sys.argv[1:] if args == ['-h']: args = ['--help'] - return args + return _normalize_password_args(args) + + +def _normalize_password_args(args: list[str]) -> list[str]: + """Extract --password/--pass/-p values that start with a dash before Click + sees them. + + Click treats tokens starting with "-" as option flags, so + "--password -mypass" and "-p-mypass" fail. This function removes the + password from the arg list and stashes it in "_extracted_password" for + later retrieval. + + Handled forms: + - "--password -mypass" / "--pass -mypass" / "-p -mypass" + - "--password=-mypass" / "--pass=-mypass" + - "-p-mypass" + """ + global _extracted_password + _extracted_password = None + + result: list[str] = [] + i = 0 + while i < len(args): + arg = args[i] + + # --password=-mypass / --pass=-mypass + for prefix in ('--password=', '--pass='): + if arg.startswith(prefix): + value = arg[len(prefix) :] + if value.startswith('-'): + _extracted_password = value + break + else: + # -p-mypass (short option with dash-prefixed value glued on) + if arg.startswith('-p-'): + _extracted_password = arg[2:] + i += 1 + continue + + # "--password -mypass" / "--pass -mypass" (two separate tokens) + if arg in ('--password', '--pass') and i + 1 < len(args): + next_arg = args[i + 1] + if next_arg.startswith('-') and next_arg != '--': + _extracted_password = next_arg + i += 2 + continue + + result.append(arg) + i += 1 + continue + i += 1 + + return result def is_valid_connection_scheme(text: str) -> tuple[bool, str | None]: diff --git a/test/pytests/test_cli_utils.py b/test/pytests/test_cli_utils.py index 1d01d3e6..77465545 100644 --- a/test/pytests/test_cli_utils.py +++ b/test/pytests/test_cli_utils.py @@ -4,6 +4,7 @@ from mycli.packages import cli_utils from mycli.packages.cli_utils import ( + _normalize_password_args, filtered_sys_argv, is_valid_connection_scheme, ) @@ -37,3 +38,35 @@ def test_filtered_sys_argv(monkeypatch, argv, expected): ) def test_is_valid_connection_scheme(text, is_valid, invalid_scheme): assert is_valid_connection_scheme(text) == (is_valid, invalid_scheme) + + +@pytest.mark.parametrize( + ('args', 'expected_args', 'expected_password'), + [ + # --password / --pass with a dash-prefixed value: extracted from args + (['--password', '-mypass'], [], '-mypass'), + (['--pass', '-mypass'], [], '-mypass'), + # --password=-mypass / --pass=-mypass: extracted from args + (['--password=-mypass'], [], '-mypass'), + (['--pass=-mypass'], [], '-mypass'), + # -p-mypass: extracted from args + (['-p-mypass'], [], '-mypass'), + # --password with a normal value is left for Click + (['--password', 'mypass'], ['--password', 'mypass'], None), + (['--password=mypass'], ['--password=mypass'], None), + # --password with -- (end of options) is left alone + (['--password', '--'], ['--password', '--'], None), + # --password at end of args (used as flag) is left alone + (['--password'], ['--password'], None), + # -p at end of args (used as flag) is left alone + (['-p'], ['-p'], None), + # other args are preserved, only the password pair is extracted + (['-u', 'root', '--password', '-mypass', '-h', 'localhost'], ['-u', 'root', '-h', 'localhost'], '-mypass'), + (['-u', 'root', '-p-mypass', '-h', 'localhost'], ['-u', 'root', '-h', 'localhost'], '-mypass'), + # -p as a flag does not absorb the next option + (['-p', '-u', 'root'], ['-p', '-u', 'root'], None), + ], +) +def test_normalize_password_args(args, expected_args, expected_password): + assert _normalize_password_args(args) == expected_args + assert cli_utils._extracted_password == expected_password