Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ Unreleased
fail. :issue:`3105` :pr:`3211`
- Add ``click.get_pager_file`` for file-like access to an output
pager. :pr:`1572`
- Show custom error messages from types when :func:`prompt` with
``hide_input=True`` fails validation, instead of always showing a
generic message. Built-in type messages mask the input value.
:issue:`2809` :pr:`3256`

Version 8.3.3
-------------
Expand Down
28 changes: 24 additions & 4 deletions src/click/termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import inspect
import io
import itertools
import re
import sys
import typing as t
from contextlib import AbstractContextManager
Expand Down Expand Up @@ -53,6 +54,27 @@
_ansi_reset_all = "\033[0m"


_HIDDEN_INPUT_MASK = "'***'"


def _mask_hidden_input(message: str, value: str) -> str:
"""Replace occurrences of ``value`` in ``message`` with a fixed mask.

Both ``repr(value)`` (the form built-in :class:`ParamType` errors use
via ``{value!r}``) and the raw value are masked. The raw-value pass
uses word-boundary lookarounds so a substring like ``"1"`` does not
match inside ``"10"``, and ``"ent"`` does not match inside
``"Authentication"``. The empty string is skipped to avoid matching
at every boundary.
"""
message = message.replace(repr(value), _HIDDEN_INPUT_MASK)
if value:
message = re.sub(
rf"(?<!\w){re.escape(value)}(?!\w)", _HIDDEN_INPUT_MASK, message
)
return message


def hidden_prompt_func(prompt: str) -> str:
import getpass

Expand Down Expand Up @@ -202,10 +224,8 @@ def prompt_func(text: str) -> str:
try:
result = value_proc(value)
except UsageError as e:
if hide_input:
echo(_("Error: The value you entered was invalid."), err=err)
else:
echo(_("Error: {e.message}").format(e=e), err=err)
message = _mask_hidden_input(e.message, value) if hide_input else e.message
echo(_("Error: {message}").format(message=message), err=err)
continue
if not confirmation_prompt:
return result
Expand Down
256 changes: 256 additions & 0 deletions tests/test_termui.py
Original file line number Diff line number Diff line change
Expand Up @@ -988,3 +988,259 @@ def cli(flag):
assert result.output == expected_output
assert not result.stderr
assert result.exit_code == 0 if expected not in (REPEAT, INVALID) else 1


class _CustomTypeNoValue(click.ParamType):
name = "custom"

def convert(self, value, param, ctx):
if len(value) < 4:
self.fail("Password must be at least 4 characters", param, ctx)
return value


class _CustomTypeWithRawValue(click.ParamType):
name = "custom_raw"

def convert(self, value, param, ctx):
if value == "bad":
self.fail(f"rejected: {value}", param, ctx)
return value


class _PasswordLengthType(click.ParamType):
"""Mirrors the issue's original use case: a password validator
that references the user-typed value in its error message without
quoting it.
"""

name = "password"

def convert(self, value, param, ctx):
if len(value) < 10:
self.fail(f"{value} is too short", param, ctx)
return value


class _MixedQuotedAndRawType(click.ParamType):
"""Custom type that mentions the user input both quoted (built-in
pattern) and raw within the same message.
"""

name = "mixed"

def convert(self, value, param, ctx):
self.fail(f"got {value!r} which is the same as {value}", param, ctx)


class _StaticMessageType(click.ParamType):
"""Custom type whose error message never references the value."""

name = "static"

def convert(self, value, param, ctx):
self.fail("Authentication failed for this account", param, ctx)


class _RejectAllRawType(click.ParamType):
"""Always rejects, with the raw value (unquoted) in the message."""

name = "reject_all_raw"

def convert(self, value, param, ctx):
self.fail(f"rejected: {value}", param, ctx)


class _MultiRawType(click.ParamType):
"""Mentions the raw value multiple times in the same message."""

name = "multi_raw"

def convert(self, value, param, ctx):
self.fail(f"got {value} but {value} is bad", param, ctx)


class _MultiReprType(click.ParamType):
"""Mentions ``repr(value)`` multiple times in the same message."""

name = "multi_repr"

def convert(self, value, param, ctx):
self.fail(f"got {value!r} and {value!r}", param, ctx)


class _ApostropheReprType(click.ParamType):
"""Custom type whose ``repr(value)`` switches to double quotes when
the value itself contains a single quote.
"""

name = "apostrophe_repr"

def convert(self, value, param, ctx):
self.fail(f"rejected {value!r}", param, ctx)


@pytest.mark.parametrize(
("type", "user_input", "expected_fragment", "unexpected_fragment"),
[
pytest.param(
click.INT,
"bad",
"'***' is not a valid integer",
"bad",
id="builtin-int-masks-repr-value",
),
pytest.param(
_CustomTypeNoValue(),
"bad",
"Password must be at least 4 characters",
None,
id="custom-no-value-shows-message",
),
pytest.param(
_CustomTypeWithRawValue(),
"bad",
"rejected: '***'",
"bad",
id="custom-raw-value-masked",
),
pytest.param(
_PasswordLengthType(),
"PASSWORD",
"'***' is too short",
"PASSWORD",
id="unquoted-custom-message-should-mask-not-fallback",
),
pytest.param(
_MixedQuotedAndRawType(),
"leakybits",
"got '***' which is the same as '***'",
"leakybits",
id="mixed-quoted-and-raw-both-masked-at-source",
),
pytest.param(
click.IntRange(min=10, max=99),
"1",
"is not in the range",
None,
id="intrange-numeric-substring-falls-back-to-generic",
),
pytest.param(
_StaticMessageType(),
"ent",
"Authentication failed for this account",
None,
id="partial-word-match-falls-back-to-generic",
),
# When the raw (unquoted) value appears in the message, mask it instead
# of replacing the whole message with a generic fallback that throws
# useful information away.
pytest.param(
_RejectAllRawType(),
"secret",
"rejected: '***'",
"secret",
id="raw-value-should-be-masked-not-fallback",
),
# When the raw value occurs more than
# once unquoted, every occurrence must be masked.
pytest.param(
_MultiRawType(),
"secret",
"got '***' but '***' is bad",
"secret",
id="multi-occurrence-raw-mask-all",
),
pytest.param(
_MultiReprType(),
"secret",
"got '***' and '***'",
"secret",
id="multi-occurrence-repr-mask-all",
),
pytest.param(
_PasswordLengthType(),
"a.b*c+",
"'***' is too short",
"a.b*c+",
id="regex-special-chars-must-be-escaped",
),
pytest.param(
_PasswordLengthType(),
"пароль",
"'***' is too short",
"пароль",
id="unicode-value-masked",
),
pytest.param(
_ApostropheReprType(),
"it's",
"rejected '***'",
"it's",
id="apostrophe-in-value-uses-double-quote-repr",
),
pytest.param(
_MixedQuotedAndRawType(),
"leakybits",
"got '***' which is the same as '***'",
"leakybits",
id="mixed-quoted-and-raw-mask-both",
),
],
)
def test_hide_input_error_message(
runner, type, user_input, expected_fragment, unexpected_fragment
):
"""https://github.com/pallets/click/issues/2809"""

@click.command()
@click.option("--password", prompt=True, hide_input=True, type=type)
def cli(password):
click.echo(password)

result = runner.invoke(cli, input=user_input)
assert expected_fragment in result.output
if unexpected_fragment is not None:
assert unexpected_fragment not in result.output


def test_hide_input_confirmation_prompt_mismatch_unaffected(runner):
"""The ``hide_input`` mask logic only applies to ``value_proc``
failures. The separate ``confirmation_prompt`` mismatch path must
keep emitting its own message, with no value leak from either entry.
"""

@click.command()
@click.option("--password", prompt=True, confirmation_prompt=True, hide_input=True)
def cli(password):
click.echo(f"got: {password}")

# First pair mismatches, second pair matches.
result = runner.invoke(cli, input="firstone\nsecondone\nfinalone\nfinalone\n")
assert "Error: The two entered values do not match." in result.output
assert "firstone" not in result.output
assert "secondone" not in result.output
# Successful prompt echoes the final value back via the command body.
assert "got: finalone" in result.output
assert result.exit_code == 0


def test_hide_input_value_never_leaks_when_err_true(runner):
"""``click.prompt(..., err=True)`` routes its error message to
stderr. The masking logic must apply on that path too: the raw
input must not appear on either stream.
"""

@click.command()
def cli():
value = click.prompt(
"Password",
hide_input=True,
type=_PasswordLengthType(),
err=True,
)
click.echo(value)

result = runner.invoke(cli, input="leaky\n", mix_stderr=False)
assert "leaky" not in result.stdout
assert "leaky" not in result.stderr
Loading