Skip to content
Draft
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
35 changes: 21 additions & 14 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@
parse_codex32,
recover_secret,
)
from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass
from codex32.correction import (
CorrectionCandidate,
CorrectionContext,
_best,
_capture_mass,
_no_correction_message,
)
from codex32.errors import (
CodexError,
DuplicateShareIndex,
Expand All @@ -47,9 +53,6 @@
_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 @@ -545,21 +548,21 @@ def _suggestions(
*,
allowed: Callable[[CorrectionCandidate], bool] | None = None,
fingerprint: Callable[[MasterSeed], bytes] | None = None,
) -> tuple[CorrectionCandidate, ...]:
) -> tuple[tuple[CorrectionCandidate, ...], bool]:
fingerprint_match = _fingerprint_matcher(fingerprint)
erased = value
if interpretation := _case_interpretation(value, prefix, profiles, allowed):
candidate, value, erased, prefix = interpretation
if candidate is not None:
return (candidate,)
return (candidate,), True
separator = value.lower().rfind("1")
if separator <= 0:
return ()
return (), True
hrp = value[:separator].lower()
rules = _optional_profile_rules(hrp)
profile = rules.profile if rules is not None else None
if profiles is not None and profile not in profiles:
return ()
return (), True
excluded = tuple(artifact.header.index for artifact in accepted)
target = len(accepted[0].text) if accepted else None
immutable = (
Expand All @@ -578,7 +581,7 @@ def _suggestions(
allowed=allowed,
deadline=monotonic() + 10,
fingerprint_match=fingerprint_match,
)[0]
)


def _validate_operational_artifact(
Expand Down Expand Up @@ -631,7 +634,7 @@ def allowed(candidate: CorrectionCandidate) -> bool:
return False
return True

candidates = _suggestions(
candidates, complete = _suggestions(
token,
"",
profiles,
Expand All @@ -641,7 +644,7 @@ def allowed(candidate: CorrectionCandidate) -> bool:
)
if len(candidates) != 1:
if isinstance(error.__cause__, CodexError):
raise InputError(NO_CORRECTION) from error
raise InputError(_no_correction_message(candidates, complete)) from error
raise
if not _confirm_correction(candidates[0], accepted, basis, fingerprint):
raise CorrectionDeclined
Expand Down Expand Up @@ -699,8 +702,8 @@ def allowed(candidate: CorrectionCandidate) -> bool:
try:
artifact = _parse(complete_value, profiles)
except InputError as error:
candidates = (
()
candidates, complete = (
((), True)
if one
else _suggestions(
complete_value,
Expand All @@ -724,7 +727,11 @@ def allowed(candidate: CorrectionCandidate) -> bool:
if confirmation is True:
artifact = candidates[0].artifact
else:
message = NO_CORRECTION if not one and isinstance(error.__cause__, CodexError) else str(error)
message = (
_no_correction_message(candidates, complete)
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
14 changes: 4 additions & 10 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from codex32.correction import (
CorrectionCandidate,
_best,
_no_correction_message,
_residue_low_discrimination,
correct_worksheet_residue,
)
Expand Down Expand Up @@ -261,7 +262,7 @@ def _creation_source(
except CodexError as error:
if not sys.stdin.isatty():
raise _UsageError(str(error)) from error
candidates = _suggestions(
candidates, complete = _suggestions(
value,
"",
(profile,),
Expand All @@ -281,10 +282,7 @@ def _creation_source(
_print("", err=True)
prefill = value
continue
_print(
"Rejected: No unique correction found. Check your codex32 backup and re-enter it carefully.",
err=True,
)
_print(f"Rejected: {_no_correction_message(candidates, complete)}", err=True)


def _confirm_card(artifact: Artifact, confirm: Callable[[str], ConfirmationResult] | None = None) -> None:
Expand Down Expand Up @@ -637,14 +635,10 @@ def _correct(
complete = True
else:
candidates, complete = _scheduled_candidates(search_value, erased, hrp, byte_length, immutable)
if not complete and not candidates:
raise _CommandError("The correction search did not complete within ten seconds.")
if not candidates:
raise _CommandError("No valid correction found. Check the original backup.")
if core is not None and len(candidates) > 1:
candidates = _best(candidates, fingerprint_match=_fingerprint_matcher(core.fingerprint))
if len(candidates) != 1:
raise _CommandError("Several corrections are possible. Check the original backup.")
raise _CommandError(_no_correction_message(candidates, complete))
fixed = candidates[0]
_require_correction_confirmation(fixed.low_checksum_discrimination)
warning = (
Expand Down
11 changes: 11 additions & 0 deletions src/codex32/correction.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ def _low_discrimination(volume: int, bits: int) -> bool:
return 32 * volume > 1 << bits


def _no_correction_message(candidates: Sequence[CorrectionCandidate], complete: bool) -> str:
"""Say why no correction is shown, never how to edit the entry."""
if len(candidates) > 1:
reason = "Several corrections are possible."
elif not complete:
reason = "The correction search ran out of time."
else:
reason = "No correction was found."
return f"{reason} Check your backup and re-enter it carefully."


def _capture_mass(layers: Sequence[tuple[int, int]], rank: int) -> tuple[int, int]:
"""Normalize all admitted equal-or-better classes, including unsearched work."""
included = tuple((volume, bits) for volume, bits in layers if volume <= rank)
Expand Down
71 changes: 45 additions & 26 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,7 +620,7 @@ def test_embedded_correction_schedules_both_mixed_case_interpretations_before_al
source = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw"
damaged = "ms10testsxPxxxxxPxxxxxPxxxxxPxxxxxP4nzvca9cmczlw"

candidates = input_module._suggestions(damaged, "", (Profile.MS,), [])
candidates, _complete = input_module._suggestions(damaged, "", (Profile.MS,), [])

assert len(candidates) == 1
assert candidates[0].artifact.text == source
Expand Down Expand Up @@ -660,9 +660,7 @@ def test_xprv_rejects_unreadable_entries_without_edit_hints(monkeypatch, capsys)
assert ms_main(["xprv"]) == 0
rejected = [line for line in capsys.readouterr().err.splitlines() if line.startswith("Rejected:")]
# 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
)
assert rejected == ["Rejected: No correction was found. Check your backup and re-enter it carefully."] * 2


def test_xprv_rejects_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys) -> None:
Expand All @@ -678,7 +676,7 @@ def test_xprv_rejects_every_uncorrectable_prefixed_ms_length(monkeypatch, capsys
assert ms_main(["xprv"]) == 0
output = capsys.readouterr().err
assert output.count(
"Rejected: No unique correction found. Check your backup and re-enter it carefully."
"Rejected: No correction was found. Check your backup and re-enter it carefully."
) == len(lengths)
assert "characters" not in output

Expand All @@ -692,7 +690,7 @@ def test_prefixed_ms_lengths_at_correction_boundary_still_search(monkeypatch, ca

def suggestions(value, *_args, **_kwargs):
searched.append(value)
return ()
return (), True

monkeypatch.setattr(input_module.sys, "stdin", _TTYInput())
monkeypatch.setattr(input_module, "_line_editor", None)
Expand All @@ -703,7 +701,7 @@ def suggestions(value, *_args, **_kwargs):
output = capsys.readouterr().err
assert [len(value) for value in searched] == list(lengths)
assert output.count(
"Rejected: No unique correction found. Check your backup and re-enter it carefully."
"Rejected: No correction was found. Check your backup and re-enter it carefully."
) == len(lengths)


Expand All @@ -714,35 +712,55 @@ def test_complete_paste_with_bad_threshold_gets_no_hint(monkeypatch, capsys) ->
monkeypatch.setattr(input_module.sys, "stdin", _TTYInput())
monkeypatch.setattr(input_module, "_line_editor", None)
monkeypatch.setattr(input_module, "_editable_input", lambda _prompt, _prefill="": next(answers))
monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ())
monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ((), True))

assert ms_main(["xprv"]) == 0
output = capsys.readouterr().err
assert "Rejected: No unique correction found. Check your backup and re-enter it carefully." in output
assert "Rejected: No correction was 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: ())
monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: ((), True))

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 "No correction was found. Check your backup and re-enter it carefully." in result.stderr
assert "character" not in result.stderr


@pytest.mark.parametrize(
("result", "reason"),
(
(((), True), "No correction was found."),
(((), False), "The correction search ran out of time."),
(((object(), object()), True), "Several corrections are possible."),
),
)
def test_redirected_rejection_says_why_no_correction_is_shown(monkeypatch, result, reason) -> None:
input_module = importlib.import_module("codex32._cli_input")
monkeypatch.setattr(input_module, "_suggestions", lambda *_args, **_kwargs: result)

rejected = _invoke(["secret"], VECTOR_1["secret_s"][:-1] + "b")

assert rejected.exit_code != 0
# Say what happened, never how to edit the entry.
assert f"{reason} Check your backup and re-enter it carefully." in rejected.stderr
assert "character" not in rejected.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: ())
monkeypatch.setattr(cli_module, "_suggestions", lambda *_args, **_kwargs: ((), True))

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"
"Rejected: No correction was found. Check your backup and re-enter it carefully.\n"
)


Expand All @@ -768,7 +786,7 @@ def answer(*_args, **kwargs):
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
assert "correction" not in output


@pytest.mark.parametrize(("difference", "word"), ((-9, "short of"), (9, "too long for")))
Expand All @@ -788,7 +806,7 @@ def test_subsequent_prefixed_ms_input_with_wrong_length_gets_no_hint(
assert main(["secret", "--plain"]) == 0
output = capsys.readouterr()
assert output.out.strip() == VECTOR_2["secret_S"]
assert "Rejected: No unique correction found. Check your backup and re-enter it carefully." in output.err
assert "Rejected: No correction was found. Check your backup and re-enter it carefully." in output.err
assert word not in output.err


Expand Down Expand Up @@ -1847,7 +1865,7 @@ def test_cli_rejects_statistically_inadmissible_structural_burst() -> None:
result = _invoke(["correct"], damaged)

assert result.exit_code == 3
assert "No valid correction found" in result.stderr
assert "No correction was found" in result.stderr


def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> None:
Expand All @@ -1858,7 +1876,7 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() ->
assert len("".join(damaged.split())) == 48
assert damaged.count("?") == 16
assert result.exit_code == 3
assert "No valid correction found" in result.stderr
assert "No correction was found" in result.stderr


def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None:
Expand Down Expand Up @@ -1910,7 +1928,7 @@ def test_cli_never_accepts_an_incomplete_structural_search() -> None:

assert result.exit_code == 3
assert result.stdout == ""
assert "did not complete" in result.stderr and original not in result.stderr
assert "ran out of time" in result.stderr and original not in result.stderr


@pytest.mark.parametrize(
Expand Down Expand Up @@ -1997,7 +2015,7 @@ def test_correct_suggests_the_majority_case_for_mixed_case_damage() -> None:

assert result.exit_code == 1
assert source in result.stderr
assert "No valid correction found" not in result.stderr
assert "No correction was found" not in result.stderr
assert wrong_length.exit_code == 2
assert "--bytes does not match" in wrong_length.stderr
assert source not in wrong_length.stderr
Expand Down Expand Up @@ -2069,7 +2087,7 @@ def test_correct_required_work_is_not_starved_by_erasure_alignment() -> None:

assert result.exit_code == 1
assert source in result.stderr
assert "did not complete within ten seconds" not in result.stderr
assert "ran out of time" not in result.stderr
assert "interactive confirmation required" not in result.stderr


Expand Down Expand Up @@ -2118,8 +2136,8 @@ def test_correction_hides_internal_candidate_reparse_failures() -> None:
assert result.exit_code == 3
assert result.stdout == ""
assert result.stderr.strip() in {
"codex32 correct: No valid correction found. Check the original backup.",
"codex32 correct: The correction search did not complete within ten seconds.",
"codex32 correct: No correction was found. Check your backup and re-enter it carefully.",
"codex32 correct: The correction search ran out of time. Check your backup and re-enter it carefully.",
}
assert "threshold" not in result.stderr

Expand Down Expand Up @@ -2876,7 +2894,7 @@ def test_creation_source_unusable_candidates_retry(monkeypatch, capsys, kind):
artifact = parse_codex32(VECTOR_6["codex32_peev"])
candidate = SimpleNamespace(artifact=artifact, search_complete=True, low_checksum_discrimination=False)
candidates = () if kind == "none" else (candidate, candidate) if kind == "ambiguous" else (candidate,)
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: candidates)
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: (candidates, True))
answers = iter(("ms1invalid", "00" * 16))
monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers))
Expand All @@ -2903,7 +2921,7 @@ def test_creation_source_does_not_label_an_incomplete_share_candidate(monkeypatc

def suggestions(*args, **kwargs):
assert not kwargs["allowed"](candidate)
return (candidate,)
return (candidate,), True

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(module, "_editable_input", lambda *args: next(answers))
Expand All @@ -2930,7 +2948,7 @@ def answer(*args):

monkeypatch.setattr(sys, "stdin", _TTYInput())
monkeypatch.setattr(module, "_editable_input", answer)
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ())
monkeypatch.setattr(cli, "_suggestions", lambda *args, **kwargs: ((), True))
with pytest.raises(exception):
cli._creation_source(Profile.MS)

Expand Down Expand Up @@ -2981,7 +2999,8 @@ def test_corrected_creation_source_identity_and_acceptance_boundary(monkeypatch)
cli,
"_suggestions",
lambda *args, **kwargs: (
SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False),
(SimpleNamespace(artifact=secret, search_complete=True, low_checksum_discrimination=False),),
True,
),
)
answers = iter(("ms1invalid", "n", "ms1invalid", "yes", "", secret.text))
Expand Down
Loading
Loading