From 809d12df221c20b3c302e2037256e4c23fcc1d5f Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Thu, 22 Oct 2026 18:01:09 -0500 Subject: [PATCH] correction: Derive contexts from shares Move recovery context derivation into the public library so CLI and future clients share validated length, immutable header, and excluded indices. Reject incompatible accepted shares and conflicting caller hints. Preserve uppercase retry prefixes when the entered share uses uppercase text. Cover accepted-share constraints and error paths with public API tests. --- docs/developer/api.md | 9 ++++++++ src/codex32/_cli_input.py | 8 ++++++-- src/codex32/correction.py | 43 +++++++++++++++++++++++++++++++++++++++ tests/test_public_api.py | 37 +++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 2589128..150af16 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -322,6 +322,15 @@ other HRP. `hrp` is a case-insensitive string stored in lowercase. The context f - `immutable_prefix`, program-supplied text outside the correction domain; and - `excluded_indices`, ordinary share indices already accepted in a recovery. +Applications restoring a share set can call +`CorrectionContext.from_shares(accepted, hrp=None, expected_length=None, immutable_prefix=None)`. +It derives the fixed length, complete header prefix, and excluded indices from +compatible, validated ordinary shares. An explicit length, prefix, or HRP may +only agree with those shares. With no accepted shares, `hrp` is required and +optional length and prefix hints have the same meaning as the direct constructor. +The CLI uses this factory for interactive recovery and standalone correction; +graphical and other clients can share that constraint logic. + With or without `expected_length`, `ms` correction searches only valid master-seed lengths reachable by a supported structural family. Another HRP considers character offsets -4 through +4 and group offsets -8/-4/0/+4/+8, diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 848b9ab..948b6b8 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -341,9 +341,13 @@ def _corrections( if isinstance(byte_length, int): target = _text_length(byte_length) immutable = prefix if prefix and value.lower().startswith(prefix.lower()) else value[: separator + 1] - excluded = tuple(artifact.share_idx for artifact in accepted) - context = CorrectionContext(hrp, target, _prefix_for_suffix(immutable, value), excluded) try: + context = CorrectionContext.from_shares( + accepted, + hrp=hrp, + expected_length=target, + immutable_prefix=_prefix_for_suffix(immutable, value), + ) candidates, complete = _correct_complete(context, value, allowed=allowed, competitors=True) except InvalidCorrectionInput: return (), True diff --git a/src/codex32/correction.py b/src/codex32/correction.py index b1d830c..86f8f5e 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -24,6 +24,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, field, replace from functools import cache, lru_cache +from itertools import islice from math import comb from time import monotonic from typing import Literal @@ -44,6 +45,7 @@ _has_generation_padding, _header, _validate_ms_length, + _validate_recovery_prefix, parse_codex32, ) from codex32.checksums import _CODEX32, _CODEX32_LONG, _Checksum @@ -80,6 +82,47 @@ def __init__( object.__setattr__(self, "immutable_prefix", immutable_prefix) object.__setattr__(self, "excluded_indices", excluded_indices) + @classmethod + def from_shares( + cls, + accepted: Sequence[Codex32String], + *, + hrp: str | None = None, + expected_length: int | None = None, + immutable_prefix: str | None = None, + ) -> "CorrectionContext": + """Constrain a correction using compatible, previously accepted ordinary shares. + + When no shares have been accepted, ``hrp`` identifies the application. + Supplied length and prefix hints may narrow, but never contradict, a set. + """ + shares = tuple(islice(accepted, 10)) + if shares: + try: + _validate_recovery_prefix(shares) + except (CodexError, TypeError, ValueError) as error: + raise InvalidCorrectionInput("accepted shares must be compatible ordinary shares") from error + first = shares[0] + if hrp is not None and (not isinstance(hrp, str) or hrp.lower() != first.hrp): + raise InvalidCorrectionInput("hrp conflicts with accepted shares") + if expected_length is not None and expected_length != len(first.s): + raise InvalidCorrectionInput("expected_length conflicts with accepted shares") + header_prefix = f"{first.hrp}1{first.k}{first.ident}" + if immutable_prefix is not None and ( + not isinstance(immutable_prefix, str) + or not header_prefix.startswith(immutable_prefix.lower()) + ): + raise InvalidCorrectionInput("immutable_prefix conflicts with accepted shares") + if immutable_prefix is not None and immutable_prefix.isupper(): + header_prefix = header_prefix.upper() + context = cls(first.hrp, len(first.s), header_prefix, tuple(share.share_idx for share in shares)) + else: + if hrp is None: + raise InvalidCorrectionInput("hrp is required without accepted shares") + context = cls(hrp, expected_length, immutable_prefix) + _validate_context(context) + return context + @dataclass(frozen=True, slots=True) class CorrectionEdit: diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 57e7231..1bf6813 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -40,6 +40,7 @@ def test_backup_workflows_need_only_the_standard_library() -> None: assert derive_share(shares, "d").share_idx == "d" damaged = shares[0].s[:-1] + "?" assert correct(CorrectionContext("ms"), damaged) +assert CorrectionContext.from_shares(shares[:1]).excluded_indices == (shares[0].share_idx,) """ environment = os.environ.copy() environment["PYTHONPATH"] = str(Path(__file__).parents[1] / "src") @@ -54,6 +55,42 @@ def test_backup_workflows_need_only_the_standard_library() -> None: assert result.returncode == 0, result.stderr +def test_correction_context_from_accepted_shares() -> None: + first = parse_codex32(VECTOR_2["share_A"]) + second = parse_codex32(VECTOR_2["share_C"]) + context = CorrectionContext.from_shares([first, second], hrp="MS", immutable_prefix="MS1") + + assert context == CorrectionContext("ms", len(first.s), "MS12NAME", ("a", "c")) + assert correct(context, first.s) == () # An accepted index cannot be suggested again. + assert CorrectionContext.from_shares([], hrp="ms", expected_length=len(first.s)) == CorrectionContext( + "ms", len(first.s) + ) + + +@pytest.mark.parametrize( + "kwargs", + ( + {"hrp": "cl"}, + {"expected_length": 74}, + {"immutable_prefix": "ms12cash"}, + ), +) +def test_correction_context_rejects_conflicting_hints(kwargs: dict[str, object]) -> None: + first = parse_codex32(VECTOR_2["share_A"]) + with pytest.raises(codex32.InvalidCorrectionInput): + CorrectionContext.from_shares([first], **kwargs) # type: ignore[arg-type] + + +def test_correction_context_rejects_incompatible_shares() -> None: + first = parse_codex32(VECTOR_2["share_A"]) + with pytest.raises(codex32.InvalidCorrectionInput): + CorrectionContext.from_shares([first, first]) + with pytest.raises(codex32.InvalidCorrectionInput): + CorrectionContext.from_shares([parse_codex32(VECTOR_1["secret_s"])]) + with pytest.raises(codex32.InvalidCorrectionInput): + CorrectionContext.from_shares([]) + + def test_python_bip32_boundary_stops_at_the_root() -> None: module = Path(__file__).parents[1] / "src" / "codex32" / "_bip32.py" tree = ast.parse(module.read_text())