From 83cadebadbaef268ba4f0d5869bb20f63049bbbb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:12:10 +0000 Subject: [PATCH 1/3] correct: Say why the entry was invalid when suggesting a repair `correct` and the "Possible correction" prompt in secret, share, wallet and create --existing showed only the repair. Print one line to stderr first, "Invalid: ", using the reason `check` already gives. Callers pass the parse error they already caught, so nothing is parsed twice. A mixed-case string now says codex32 strings are all uppercase or all lowercase and that either case recovers the same wallet. Closes #85 Claude-Session: https://claude.ai/code/session_015CuLXqAvAovfoVcUmmogwa --- src/codex32/_cli_input.py | 10 +++++-- src/codex32/cli.py | 10 ++++--- tests/test_cli.py | 45 ++++++++++++++++++++++++----- tests/test_correction_disclosure.py | 2 +- 4 files changed, 52 insertions(+), 15 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index cf5d25d..1165be2 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -27,6 +27,7 @@ CodexError, DuplicateShareIndex, ExistingTargetIndex, + InvalidCase, InvalidChecksum, InvalidLength, InvalidThreshold, @@ -204,6 +205,7 @@ def _confirm_correction( candidate: CorrectionCandidate, accepted: list[Artifact], basis: bool, + reason: str, fingerprint: Callable[[MasterSeed], bytes] | None = None, ) -> bool | None: _require_correction_confirmation(candidate.low_checksum_discrimination) @@ -226,6 +228,7 @@ def _confirm_correction( except CodexError: _stderr("Rejected: Could not recover a valid Bitcoin master seed using this correction.") return None + _stderr(f"Invalid: {reason}") _stderr(f"Possible correction:\n\n{fingerprint_text}{_card_text(artifact.text, sys.stderr.isatty())}\n") return _confirmation_input( "Does this entire string exactly match your recovery card? [y/N]: " @@ -644,7 +647,7 @@ def _redirected( for token in tokens: try: artifact = _parse(token, profiles) - except InputError: + except InputError as error: if one: raise @@ -671,7 +674,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: ) if len(candidates) != 1: raise - if not _confirm_correction(candidates[0], accepted, basis, fingerprint): + if not _confirm_correction(candidates[0], accepted, basis, str(error), fingerprint): raise CorrectionDeclined artifact = candidates[0].artifact _validate_operational_artifact( @@ -686,6 +689,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: _FRIENDLY_SET_ERRORS: dict[type[Exception], str] = { + InvalidCase: "A codex32 string is all uppercase or all lowercase; either case recovers the same wallet.", InvalidChecksum: "The checksum does not match.", MismatchedProfile: "These strings are for different applications.", MismatchedThreshold: "These strings require different numbers of shares.", @@ -786,7 +790,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: ) ) confirmation = ( - _confirm_correction(candidates[0], accepted, basis, fingerprint) + _confirm_correction(candidates[0], accepted, basis, str(error), fingerprint) if len(candidates) == 1 else None ) diff --git a/src/codex32/cli.py b/src/codex32/cli.py index 6e81e43..7881f46 100644 --- a/src/codex32/cli.py +++ b/src/codex32/cli.py @@ -25,6 +25,7 @@ _confirm_correction, _entered_groups, _fingerprint_matcher, + _parse, _render_groups, _require_correction_confirmation, _scheduled_candidates, @@ -264,7 +265,7 @@ def _creation_source( and isinstance(candidate := candidates[0].artifact, Secret) and candidate.profile is profile ): - confirmation = _confirm_correction(candidates[0], [], False, fingerprint) + confirmation = _confirm_correction(candidates[0], [], False, str(error), fingerprint) if confirmation is True: return candidate if confirmation is False: @@ -603,9 +604,9 @@ def _correct( if context.master_seed and hrp != Profile.MS.value: raise _UsageError("This command accepts only Bitcoin master-seed input beginning with ms1.") try: - parse_codex32(normalized) - except CodexError: - pass + _parse(normalized, None) + except _UsageError as error: + reason = str(error) else: if isinstance(byte_length, int) and len(normalized) != _ms_text_length(byte_length): raise _UsageError("--bytes does not match the valid master-seed backup length.") @@ -639,6 +640,7 @@ def _correct( raise _CommandError("Several corrections are possible. Check the original backup.") fixed = candidates[0] _require_correction_confirmation(fixed.low_checksum_discrimination) + _print(f"Invalid: {reason}", err=True) if context.master_seed: core = core or _connected_core("correct") warning = ( diff --git a/tests/test_cli.py b/tests/test_cli.py index 889b175..52ae368 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -376,8 +376,9 @@ def answer(prompt: str) -> str: assert sys.stdout is not sys.stderr assert prompts == ["Enter a codex32 string:\n> "] * 2 assert rejected not in captured.out - assert "Rejected: Use either all uppercase or all lowercase letters." in captured.err - assert "Rejected: Use either all uppercase or all lowercase letters.\n\n" in captured.err + assert ( + "Rejected: A codex32 string is all uppercase or all lowercase; either case recovers the same wallet.\n\n" + ) in captured.err assert captured.err.endswith("\n\n") @@ -1760,6 +1761,33 @@ def test_cli_rejects_sixteen_consecutive_erasures_as_outside_regular_bound() -> assert "No valid correction found" in result.stderr +_UPPER_SECRET = VECTOR_1["secret_s"].upper() + + +@pytest.mark.parametrize( + ("damaged", "reason"), + ( + (_UPPER_SECRET[:-1] + "w", "A codex32 string is all uppercase or all lowercase;"), + ( + _UPPER_SECRET[:3] + "A" + _UPPER_SECRET[4:], + "The threshold must be 0 or a number from 2 through 9;", + ), + ( + _UPPER_SECRET[:8] + "A" + _UPPER_SECRET[9:], + "An unshared secret (threshold 0) must use S as its index.", + ), + (_UPPER_SECRET[:12] + "B" + _UPPER_SECRET[13:], "The character 'b' is not allowed"), + (_UPPER_SECRET[:20] + "Q" + _UPPER_SECRET[20:], "This input has 49 characters."), + ), +) +def test_correct_says_why_the_input_was_invalid(damaged: str, reason: str) -> None: + result = _invoke(["correct"], damaged) + + assert result.exit_code == 1 + assert result.stderr.startswith(f"Invalid: {reason}") + assert result.stderr.endswith(f"{_UPPER_SECRET}\n") + + def test_correct_rejects_malformed_immutable_hrp_as_usage() -> None: damaged = "é" + VECTOR_1["secret_s"][1:] @@ -2537,8 +2565,11 @@ def test_operational_candidate_whole_card_confirmation(monkeypatch, capsys, resp monkeypatch.setattr(module, "_editable_input", lambda prompt: prompts.append(prompt) or response) monkeypatch.setattr(module.sys.stderr, "isatty", lambda: True) candidate = CorrectionCandidate(artifact, (), 1, 0, 0, None, capture_space_bits=65) - assert module._confirm_correction(candidate, [], False, _FakeBitcoinCore().fingerprint) is accepted + reason = "The checksum does not match." + fingerprint = _FakeBitcoinCore().fingerprint + assert module._confirm_correction(candidate, [], False, reason, fingerprint) is accepted output = capsys.readouterr().err + assert output.startswith(f"Invalid: {reason}\nPossible correction:\n\n") assert "Master fingerprint: 3F3521A6\n\n" in output assert module._card_text(artifact.text) in output assert "> " not in output @@ -2556,12 +2587,12 @@ def test_final_share_preview_is_isolated_and_basis_has_no_preview(monkeypatch, c accepted = [first] monkeypatch.setattr(module.sys, "stdin", _TTYInput()) monkeypatch.setattr(module, "_editable_input", lambda prompt: "n") - assert not module._confirm_correction(candidate, accepted, False, _FakeBitcoinCore().fingerprint) + assert not module._confirm_correction(candidate, accepted, False, "", _FakeBitcoinCore().fingerprint) assert accepted == [first] assert "Master fingerprint: FAB6868A\n\n" in capsys.readouterr().err - assert not module._confirm_correction(candidate, accepted, True, _FakeBitcoinCore().fingerprint) + assert not module._confirm_correction(candidate, accepted, True, "", _FakeBitcoinCore().fingerprint) assert "Master fingerprint" not in capsys.readouterr().err - assert not module._confirm_correction(candidate, [], False, _FakeBitcoinCore().fingerprint) + assert not module._confirm_correction(candidate, [], False, "", _FakeBitcoinCore().fingerprint) assert "Master fingerprint" not in capsys.readouterr().err @@ -2576,7 +2607,7 @@ def fail(seed): candidate = CorrectionCandidate( parse_codex32(VECTOR_1["secret_s"]), (), 1, 0, 0, None, capture_space_bits=65 ) - assert not module._confirm_correction(candidate, [], False, fail) + assert not module._confirm_correction(candidate, [], False, "", fail) assert "Could not recover a valid Bitcoin master seed using this correction." in capsys.readouterr().err diff --git a/tests/test_correction_disclosure.py b/tests/test_correction_disclosure.py index 08592ee..719f41d 100644 --- a/tests/test_correction_disclosure.py +++ b/tests/test_correction_disclosure.py @@ -203,7 +203,7 @@ def test_yes_still_requires_independent_whole_card_acceptance(monkeypatch): answers = iter(("YES", "n")) monkeypatch.setattr(_cli_input, "_editable_input", lambda prompt: prompts.append(prompt) or next(answers)) with patch.object(sys, "stdin", _TTYInput()), contextlib.redirect_stderr(_TTYOutput()): - assert _cli_input._confirm_correction(candidate, [], False) is False + assert _cli_input._confirm_correction(candidate, [], False, "") is False assert prompts == [ "If you understand this, type YES to attempt to correct the data: ", "Does this entire string exactly match your recovery card? [y/N]: ", From dc4bf7bbc223f1e2c38b87ee15c1972ac5d617e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 13:59:40 +0000 Subject: [PATCH 2/3] cli: Word the case error for every profile The mixed-case message said either case "recovers the same wallet", but `_parse` shows it for every profile, including shares and application prefixes with no wallet. Say that both cases decode to the same data. Claude-Session: https://claude.ai/code/session_015CuLXqAvAovfoVcUmmogwa --- src/codex32/_cli_input.py | 2 +- tests/test_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 1165be2..dd36dfc 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -689,7 +689,7 @@ def allowed(candidate: CorrectionCandidate) -> bool: _FRIENDLY_SET_ERRORS: dict[type[Exception], str] = { - InvalidCase: "A codex32 string is all uppercase or all lowercase; either case recovers the same wallet.", + InvalidCase: "A codex32 string is all uppercase or all lowercase; both cases decode to the same data.", InvalidChecksum: "The checksum does not match.", MismatchedProfile: "These strings are for different applications.", MismatchedThreshold: "These strings require different numbers of shares.", diff --git a/tests/test_cli.py b/tests/test_cli.py index 52ae368..e4dc57f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -377,7 +377,7 @@ def answer(prompt: str) -> str: assert prompts == ["Enter a codex32 string:\n> "] * 2 assert rejected not in captured.out assert ( - "Rejected: A codex32 string is all uppercase or all lowercase; either case recovers the same wallet.\n\n" + "Rejected: A codex32 string is all uppercase or all lowercase; both cases decode to the same data.\n\n" ) in captured.err assert captured.err.endswith("\n\n") From 96ac5aabb92cfa7645182a58c53e7a36b2dee49c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:34:05 +0000 Subject: [PATCH 3/3] test: Raise the size budget to 5,250 lines Ben authorized raising the budget so #91 fits. The stack tip with the open fix PRs was at 5,197 of 5,200, and #91 adds 26 lines. Update the enforcing test and both places that document the number. Claude-Session: https://claude.ai/code/session_015CuLXqAvAovfoVcUmmogwa --- AGENTS.md | 2 +- docs/developer/api.md | 2 +- tests/test_cli.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fd47d95..cf7df8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,7 +61,7 @@ avoid comments or tests that restate the implementation. Add or update concise docstrings when changing public behavior. Write codex32 in lowercase except when referring to the Codex32 Book. -Keep the installed package below 5,200 logical review lines, as enforced by the +Keep the installed package below 5,250 logical review lines, as enforced by the existing test. New dependencies, public API signature or return-shape changes, and lint suppressions require user authorization; an explicit request can already provide that authorization. diff --git a/docs/developer/api.md b/docs/developer/api.md index 4e6cd06..dadf99f 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -105,7 +105,7 @@ unsupported but remains in the review scope. ### Size budget -V1 keeps the installed package below 5,200 logical review lines, excluding +V1 keeps the installed package below 5,250 logical review lines, excluding blank and comment-only lines while counting subpackages recursively. Changing the budget requires explicit review and authorization together with the matching documentation and enforcement update. diff --git a/tests/test_cli.py b/tests/test_cli.py index e4dc57f..b96e7d7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2221,7 +2221,7 @@ def test_production_size_budgets_are_enforced() -> None: for path in package.rglob("*.py") } - assert sum(counts.values()) < 5200, counts + assert sum(counts.values()) < 5250, counts @pytest.mark.parametrize(