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())