diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 0d2a41e..9d9f9b4 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -29,7 +29,6 @@ ExistingTargetIndex, InvalidChecksum, InvalidLength, - InvalidThreshold, MismatchedIdentifier, MismatchedPayloadLength, MismatchedProfile, @@ -45,7 +44,6 @@ Artifact = Share | Secret _MAX_INPUT = 9 * 1025 -_MAX_CORRECTION_LENGTH_DELTA = 8 _line_editor: Any try: @@ -54,6 +52,9 @@ _line_editor = None +NO_CORRECTION = "No unique correction found. Check your backup and re-enter it carefully." + + class InputError(Exception): pass @@ -618,7 +619,7 @@ def _redirected( for token in tokens: try: artifact = _parse(token, profiles) - except InputError: + except InputError as error: if one: raise @@ -644,6 +645,8 @@ def allowed(candidate: CorrectionCandidate) -> bool: fingerprint=fingerprint, ) if len(candidates) != 1: + if isinstance(error.__cause__, CodexError): + raise InputError(NO_CORRECTION) from error raise if not _confirm_correction(candidates[0], accepted, basis, fingerprint): raise CorrectionDeclined @@ -670,41 +673,6 @@ def allowed(candidate: CorrectionCandidate) -> bool: } -def _prefixed_input_error( - error: InputError, - value: str, - prefix: str, - accepted: list[Artifact], -) -> str: - cause = error.__cause__ - if isinstance(cause, InvalidThreshold) and prefix.lower() == "ms1": - found = value[len(prefix) : len(prefix) + 1].lower() - if found: - return f"After the prefilled {prefix}, enter a threshold of 0 or 2 through 9; found {found!r}." - if not isinstance(cause, InvalidChecksum): - return str(error) - if accepted: - if accepted[0].profile is not Profile.MS: - return str(error) - target = len(accepted[0].text) - difference = len(value) - target - if abs(difference) <= _MAX_CORRECTION_LENGTH_DELTA: - return str(error) - direction = "too long for" if difference > 0 else "short of" - return ( - f"The string is {abs(difference)} characters {direction} the required {target}-character length." - ) - if prefix.lower() != "ms1": - return str(error) - if min(abs(len(value) - target) for target in TEXT_LENGTHS) <= _MAX_CORRECTION_LENGTH_DELTA: - return str(error) - lengths = ", ".join(str(length) for length in TEXT_LENGTHS[:-1]) + f", or {TEXT_LENGTHS[-1]}" - return ( - f"The string has {len(value)} characters; valid Bitcoin master-seed codex32 lengths are " - f"{lengths} characters." - ) - - def _interactive( *, basis: bool, @@ -772,11 +740,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: if confirmation is True: artifact = candidates[0].artifact else: - message = ( - _prefixed_input_error(error, complete_value, prefix, accepted) - if supplied_prefix - else str(error) - ) + message = NO_CORRECTION if not one and isinstance(error.__cause__, CodexError) else str(error) _stderr(f"Rejected: {message}\n") prefill = _retry_text(entered, prefix) prefix = _prefix_for_suffix(prefix, prefill) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 8bd2a84..a9ff364 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -57,10 +57,11 @@ generate_master_seed, ) from codex32.profiles import Profile, _profile_rules -from codex32.profiles.ms32 import MasterSeed from codex32.profiles.ms32 import ( - _text_length as _ms_text_length, + SEED_BYTE_LENGTHS, + MasterSeed, ) +from codex32.profiles.ms32 import _text_length as _ms_text_length from codex32.wallet import master_xprv Artifact = Share | Secret @@ -245,11 +246,17 @@ def _creation_source( prompt_end=":\n> ", ) prefill = "" - try: - if value: - return bytes.fromhex(value) - except ValueError: - pass + if value and all(character in "0123456789abcdefABCDEF" for character in value): + if len(value) not in {2 * size for size in SEED_BYTE_LENGTHS}: + message = ( + "Hexadecimal seeds must contain 16, 20, 24, 28, 32, or 64 bytes. " + "Re-enter the entire seed carefully." + ) + if not sys.stdin.isatty(): + raise _UsageError(message) + _print(f"Rejected: {message}", err=True) + continue + return bytes.fromhex(value) try: return parse_codex32(value) except CodexError as error: @@ -276,7 +283,7 @@ def _creation_source( prefill = value continue _print( - f"Rejected: {error} Re-enter the existing secret or hexadecimal seed.", + "Rejected: No unique correction found. Check your codex32 backup and re-enter it carefully.", err=True, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 4d930a4..ac9f503 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -648,7 +648,7 @@ def answer(prompt: str, _prefill: str = "") -> str: assert captured.out.strip() == VECTOR_2["xprv"] -def test_xprv_explains_prefilled_threshold_and_uncorrectable_length(monkeypatch, capsys) -> None: +def test_xprv_rejects_unreadable_entries_without_edit_hints(monkeypatch, capsys) -> None: input_module = importlib.import_module("codex32._cli_input") short = "2 NAME DLL4 F8JL H4E5 VDVU LDLF XU2J HDNL SM9" answers = iter(("NAME DLL4 F8JL H4E5 VDVU LDLF XU2J HDNL SM97", short, VECTOR_1["secret_s"])) @@ -659,16 +659,13 @@ def test_xprv_explains_prefilled_threshold_and_uncorrectable_length(monkeypatch, assert ms_main(["xprv"]) == 0 rejected = [line for line in capsys.readouterr().err.splitlines() if line.startswith("Rejected:")] - assert rejected == [ - "Rejected: After the prefilled MS1, enter a threshold of 0 or 2 through 9; found 'n'.", - ( - "Rejected: The string has 39 characters; valid Bitcoin master-seed codex32 lengths are " - "48, 54, 61, 67, 74, or 127 characters." - ), - ] + # A hint about what is wrong invites a guess, and a wrong guess can make a wrong correction findable. + assert ( + rejected == ["Rejected: No unique correction found. Check your backup and re-enter it carefully."] * 2 + ) -def test_xprv_reports_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys) -> None: +def test_xprv_rejects_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys) -> None: input_module = importlib.import_module("codex32._cli_input") lengths = (39, 100, 136) invalid = tuple(("MS10TESTS" + "Q" * length)[3:length] for length in lengths) @@ -680,13 +677,13 @@ def test_xprv_reports_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys assert ms_main(["xprv"]) == 0 output = capsys.readouterr().err - for length in lengths: - assert f"The string has {length} characters; valid Bitcoin master-seed codex32 lengths are" in output + assert output.count( + "Rejected: No unique correction found. Check your backup and re-enter it carefully." + ) == len(lengths) + assert "characters" not in output -def test_prefixed_ms_lengths_at_correction_boundary_explain_length_and_still_search( - monkeypatch, capsys -) -> None: +def test_prefixed_ms_lengths_at_correction_boundary_still_search(monkeypatch, capsys) -> None: input_module = importlib.import_module("codex32._cli_input") lengths = (40, 135) invalid = tuple(("MS10TESTS" + "Q" * length)[3:length] for length in lengths) @@ -705,13 +702,12 @@ def suggestions(value, *_args, **_kwargs): assert ms_main(["xprv"]) == 0 output = capsys.readouterr().err assert [len(value) for value in searched] == list(lengths) - for length in lengths: - assert f"Rejected: This input has {length} characters." in output - assert "The checksum does not match." not in output - assert "valid Bitcoin master-seed codex32 lengths" not in output + assert output.count( + "Rejected: No unique correction found. Check your backup and re-enter it carefully." + ) == len(lengths) -def test_complete_paste_keeps_ordinary_threshold_error(monkeypatch, capsys) -> None: +def test_complete_paste_with_bad_threshold_gets_no_hint(monkeypatch, capsys) -> None: input_module = importlib.import_module("codex32._cli_input") answers = iter(("MS1NAME DLL4 F8JL H4E5 VDVU LDLF XU2J HDNL SM97", VECTOR_1["secret_s"])) @@ -722,12 +718,61 @@ def test_complete_paste_keeps_ordinary_threshold_error(monkeypatch, capsys) -> N assert ms_main(["xprv"]) == 0 output = capsys.readouterr().err - assert "Rejected: The threshold must be 0 or a number from 2 through 9; found 'n'." in output - assert "After the prefilled" not in output + assert "Rejected: No unique correction found. Check your backup and re-enter it carefully." in output + assert "threshold" not in output + + +def test_redirected_uncorrectable_input_gets_no_hint(monkeypatch) -> None: + input_module = importlib.import_module("codex32._cli_input") + monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ()) + + result = _invoke(["secret"], VECTOR_1["secret_s"][:-1] + "b") + + assert result.exit_code != 0 + assert "No unique correction found. Check your backup and re-enter it carefully." in result.stderr + assert "character" not in result.stderr + + +def test_existing_seed_without_unique_correction_gets_no_hint(monkeypatch, capsys) -> None: + cli_module = importlib.import_module("codex32.cli") + answers = iter((VECTOR_1["secret_s"][:-1] + "b", "00" * 16)) + monkeypatch.setattr(cli_module.sys, "stdin", _TTYInput()) + monkeypatch.setattr(cli_module, "_text", lambda *_args, **_kwargs: next(answers)) + monkeypatch.setattr(cli_module, "_suggestions", lambda *_args, **_kwargs: ()) + + assert cli_module._creation_source(Profile.MS) == bytes(16) + assert capsys.readouterr().err == ( + "Rejected: No unique correction found. Check your codex32 backup and re-enter it carefully.\n" + ) + + +@pytest.mark.parametrize("bad_hex", ("ab" * 15, "ab" * 17, "a" * 33, "ab" * 33)) +def test_creation_source_reprompts_on_invalid_hex_length(monkeypatch, capsys, bad_hex): + cli_module = importlib.import_module("codex32.cli") + answers = iter((bad_hex, "00" * 16)) + seen_prefills = [] + monkeypatch.setattr(cli_module.sys, "stdin", _TTYInput()) + + def answer(*_args, **kwargs): + seen_prefills.append(kwargs.get("prefill", None)) + return next(answers) + + monkeypatch.setattr(cli_module, "_text", answer) + monkeypatch.setattr( + cli_module, + "_suggestions", + lambda *_args, **_kwargs: pytest.fail("Hexadecimal seed must not be error-corrected"), + ) + + assert cli_module._creation_source(Profile.MS) == bytes(16) + assert seen_prefills == ["", ""] + output = capsys.readouterr().err + assert "Hexadecimal seeds must contain 16, 20, 24, 28, 32, or 64 bytes" in output + assert "No unique correction" not in output @pytest.mark.parametrize(("difference", "word"), ((-9, "short of"), (9, "too long for"))) -def test_subsequent_prefixed_ms_input_reports_required_set_length( +def test_subsequent_prefixed_ms_input_with_wrong_length_gets_no_hint( monkeypatch, capsys, difference, word ) -> None: input_module = importlib.import_module("codex32._cli_input") @@ -743,10 +788,8 @@ def test_subsequent_prefixed_ms_input_reports_required_set_length( assert main(["secret", "--plain"]) == 0 output = capsys.readouterr() assert output.out.strip() == VECTOR_2["secret_S"] - assert ( - f"Rejected: The string is 9 characters {word} the required {len(VECTOR_2['share_C'])}-character " - "length." - ) in output.err + assert "Rejected: No unique correction found. Check your backup and re-enter it carefully." in output.err + assert word not in output.err @pytest.mark.parametrize( @@ -1416,7 +1459,7 @@ def test_cli_import_preserves_all_bip93_master_seed_sizes(byte_length: int) -> N def test_cli_import_rejects_every_other_16_through_64_byte_size() -> None: for byte_length in set(range(16, 65)) - set(SEED_BYTE_LENGTHS): result = _invoke_confirmed_create(["create", "--existing"], bytes(byte_length).hex()) - assert result.exit_code == 1 + assert result.exit_code == 2 # Invalid length clears the field; EOF ends the retry. assert result.stdout == "" assert "16, 20, 24, 28, 32, or 64" in result.stderr diff --git a/tests/test_cli_lengths.py b/tests/test_cli_lengths.py index 6fa1089..f5471ed 100644 --- a/tests/test_cli_lengths.py +++ b/tests/test_cli_lengths.py @@ -3,7 +3,7 @@ import pytest from codex32 import Profile -from codex32._cli_input import InputError, _parse, _prefixed_input_error +from codex32._cli_input import InputError, _parse from codex32.errors import InvalidChecksum, InvalidLength @@ -31,15 +31,12 @@ def test_generic_length_profile_length_and_checksum_diagnostics(length, message, _parse(value, (Profile.MS,)) assert str(failure.value) == message assert isinstance(failure.value.__cause__, cause) - if cause is InvalidLength: - assert _prefixed_input_error(failure.value, value, "MS1", []) == message def test_reported_invalid_length_vector_in_neutral_entry(): value = "ms10fauxs" + "x" * 73 + "r335l5tv88js3" - with pytest.raises(InputError, match="This input has 95 characters") as failure: + with pytest.raises(InputError, match="This input has 95 characters"): _parse(value, (Profile.MS,)) - assert _prefixed_input_error(failure.value, value, "", []) == str(failure.value) def test_unknown_profile_does_not_replace_checksum_failure_with_a_length_guess():