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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/developer/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions src/codex32/_cli_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
CodexError,
DuplicateShareIndex,
ExistingTargetIndex,
InvalidCase,
InvalidChecksum,
InvalidLength,
InvalidThreshold,
Expand Down Expand Up @@ -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)
Expand All @@ -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]: "
Expand Down Expand Up @@ -644,7 +647,7 @@ def _redirected(
for token in tokens:
try:
artifact = _parse(token, profiles)
except InputError:
except InputError as error:
if one:
raise

Expand All @@ -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(
Expand All @@ -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; 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.",
Expand Down Expand Up @@ -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
)
Expand Down
10 changes: 6 additions & 4 deletions src/codex32/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
_confirm_correction,
_entered_groups,
_fingerprint_matcher,
_parse,
_render_groups,
_require_correction_confirmation,
_scheduled_candidates,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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 = (
Expand Down
47 changes: 39 additions & 8 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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; both cases decode to the same data.\n\n"
) in captured.err
assert captured.err.endswith("\n\n")


Expand Down Expand Up @@ -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:]

Expand Down Expand Up @@ -2193,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(
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion tests/test_correction_disclosure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ",
Expand Down
Loading