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
50 changes: 7 additions & 43 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
ExistingTargetIndex,
InvalidChecksum,
InvalidLength,
InvalidThreshold,
MismatchedIdentifier,
MismatchedPayloadLength,
MismatchedProfile,
Expand All @@ -45,7 +44,6 @@

Artifact = Share | Secret
_MAX_INPUT = 9 * 1025
_MAX_CORRECTION_LENGTH_DELTA = 8

_line_editor: Any
try:
Expand All @@ -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

Expand Down Expand Up @@ -618,7 +619,7 @@ def _redirected(
for token in tokens:
try:
artifact = _parse(token, profiles)
except InputError:
except InputError as error:
if one:
raise

Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
23 changes: 15 additions & 8 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
)

Expand Down
97 changes: 70 additions & 27 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]))
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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"]))

Expand All @@ -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")
Expand All @@ -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(
Expand Down Expand Up @@ -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

Expand Down
7 changes: 2 additions & 5 deletions tests/test_cli_lengths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand Down
Loading