diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..98950e15 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -19,14 +19,15 @@ absolute size bound. The application parser selects the checksum type from the generic encoded length and validates the common header before verifying the outer checksum. Only afterward may the HRP select a registered application profile. The parser then checks its application length and payload semantics. +When the checksum fails at a length the application never uses, the parser +reports that length instead of the checksum; this only chooses the reason for +a rejection. An unregistered HRP crosses the boundary only as an opaque base `Secret` or `Share`, with normalized `hrp`, `profile is None`, and no invented payload meaning. -CLI input diagnostics preserve generic container, header, and length failures. -After a checksum failure, the CLI may report an unsupported profile text length -instead. This diagnostic does not validate the input or change parser ordering; -alignment correction remains available. Frozen-prefix hints do not override a -generic parse-length failure. +CLI input diagnostics preserve generic container, header, and length failures; +alignment correction remains available after a length failure. Frozen-prefix +hints do not override a generic parse-length failure. ### Specification-to-code map diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 6303a63e..74913893 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -27,8 +27,6 @@ CodexError, DuplicateShareIndex, ExistingTargetIndex, - InvalidChecksum, - InvalidLength, ) from codex32.profiles import Profile, _optional_profile_rules, _profile_rules from codex32.profiles.ms32 import ( @@ -300,18 +298,7 @@ def _parse(value: str, profiles: tuple[Profile, ...] | None) -> Artifact: try: artifact = parse_codex32(value) except CodexError as error: - message = str(error) - if isinstance(error, InvalidChecksum): - # Parsing has already checked the container and generic length. - # Explain an unsupported profile length without validating input - # or changing the parser's checksum-before-profile boundary. - try: - _profile_rules(value[: value.rfind("1")]).validate_text_length(len(value)) - except InvalidLength as length_error: - message = str(length_error) - except CodexError: - pass - raise InputError(message) from error + raise InputError(str(error)) from error if profiles is not None and artifact.profile not in profiles: allowed = " or ".join(_profile_rules(profile).label for profile in profiles) raise InputError(f"This command accepts only {allowed} input.") diff --git a/src/codex32/bip93.py b/src/codex32/bip93.py index 7746d75c..8e3e147a 100644 --- a/src/codex32/bip93.py +++ b/src/codex32/bip93.py @@ -102,9 +102,12 @@ def _decode_codex32(text: str) -> tuple[str, _ProfileRules | None, tuple[int, .. checksum = _checksum_for_encoded_length(hrp, len(encoded)) body = tuple(encoded[: -checksum.length]) Header._from_symbols(body[:6]) + profile_rules = _optional_profile_rules(hrp) if not bech32_verify_checksum(hrp, encoded, checksum): + if profile_rules is not None: + # A length the application never uses is the clearer reason. + profile_rules.validate_text_length(len(text)) raise InvalidChecksum("The checksum does not match.") - profile_rules = _optional_profile_rules(hrp) if profile_rules is not None: profile_rules.validate_text_length(len(text)) profile_rules.validate_payload_length(len(body) - 6) diff --git a/tests/test_cli_lengths.py b/tests/test_cli_lengths.py index f5471edc..3be0a545 100644 --- a/tests/test_cli_lengths.py +++ b/tests/test_cli_lengths.py @@ -21,7 +21,7 @@ "This input has 95 characters. A Bitcoin master-seed backup must have " "exactly 48, 54, 61, 67, 74, or 127 characters." ), - InvalidChecksum, + InvalidLength, ), ), ) diff --git a/tests/test_profiles.py b/tests/test_profiles.py index c70174d7..2d474b93 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -76,8 +76,7 @@ def test_pr2258_supported_size_vectors(seed_hex: str, text: str) -> None: @pytest.mark.parametrize("text", FORMERLY_VALID_UNSUPPORTED_MS) def test_pr2258_rejects_formerly_valid_unsupported_sizes(text: str) -> None: - expected = InvalidChecksum if text in FORMERLY_VALID_UNSUPPORTED_MS[-2:] else InvalidLength - with pytest.raises(expected): + with pytest.raises(InvalidLength): parse_codex32(text) @@ -138,17 +137,15 @@ def test_common_header_is_validated_before_checksum() -> None: ("hrp", "payload_length"), (("ms", 26), ("cl", 52), ("bip39_12w", 27), ("bip39_24w", 53)), ) -def test_checksum_precedes_profile_length(hrp: str, payload_length: int) -> None: +def test_unused_profile_length_is_reported_instead_of_checksum(hrp: str, payload_length: int) -> None: valid = _oracle_encode(hrp, "0tests" + "q" * payload_length) - with pytest.raises(InvalidChecksum): + with pytest.raises(InvalidLength, match=f"This input has {len(valid) - 1} characters"): parse_codex32(valid[:-1]) -def test_generic_length_and_checksum_precede_ms_length() -> None: +def test_generic_length_precedes_ms_length() -> None: with pytest.raises(InvalidLength, match="at least 21"): parse_codex32("ms1") - with pytest.raises(InvalidChecksum): - parse_codex32("ms10tests" + "q" * 40) def test_valid_profile_length_still_reports_checksum_failure() -> None: