diff --git a/CHANGELOG.md b/CHANGELOG.md index ded1df8c8..0727ea7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1658,6 +1658,69 @@ documented at release-notes length in the first place, and are still in ### The public API and the module layout +- **`bytes_from_octets` is the whole of what `Octets` means, and it is + total** (issue #744). It is the coercion every `Octets` parameter of + the library runs through, and it had two holes: a hex string that is + not one left through `bytes.fromhex`'s bare `ValueError`, and anything + that was not a `str` went through *untouched* -- so `len` of a tuple + of 33 ints was 33, which is how + `taproot.assert_valid_control_block` accepted one as a control block + size. Both are refused now, as `BTClibValueError` and + `BTClibTypeError`; every buffer is still taken, and returned as it + came, a read being no place to rewrite the field it reads. + + The message is `bytes.fromhex`'s own, which names a position and never + the string. That is deliberate: an `Octets` parameter is candidate key + material as often as not, and `to_prv_key` puts this very message + inside its own "not a private key" (issue #137). + + `int_from_integer` gets both through it, and `to_prv_key`'s two "it + must be octets" fallbacks now catch a `TypeError` beside the + `ValueError`, as `to_pub_key` already did: what is neither octets nor + a spelling of them means the same thing a wrong size does. + + `mnemonic.entropy`'s five `int(x, 2)` parses and its two `int(x, 16)` + and `int(x)` ones are `BTClibValueError` too, and say a base rather + than the digits -- raw entropy being seed material, as every other + message in that module already assumed. `borromean.sign` checks that + its rings, signing indexes and nonces are of one length rather than + leaving it to `zip(strict=True)`, whose message named "argument 3" and + no parameter of the function; `strict=True` stays, as the assertion + that the check and the loops cannot drift apart. + + **What moves for a caller**: the class is narrower and the control + flow identical, `BTClibValueError` being a `ValueError` and + `BTClibTypeError` a `TypeError`. A test or a caller matching on + `bytes.fromhex`'s message still matches -- it is carried through -- + and one matching the *class* now has a btclib one to match. + `docs/source/guide.rst` shows the new spelling. + +- **Ten places in `block/`, `hashes` and `utils` asked a value what it + is not** (issue #744): a comparison, an arithmetic operation or an + attribute lookup on an argument nothing had checked. `"5" <= 16`, + `"2015" + 1` and `"hard" <= 0` are bare `TypeError`s about operands, + raised from underneath the library and naming neither the parameter + nor the function; `.tzinfo` on a str is an `AttributeError`, which is + outside *both* halves of this library's exception contract, so nothing + a caller is told to catch would have caught it; and two datetimes + given as unix timestamps subtracted to an int, whose missing + `total_seconds` is an `AttributeError` again. + + The guard is `var_int.serialize`'s, and the vocabulary is + `utils.is_integer`'s: a bool is not a number, because it would be the + height one, the block count one, the leaf index one. + + `bip34_commitment` and, through it, `Block.assert_valid_coinbase_height`; + `BlockHeader.assert_valid`'s timestamp and `assert_valid_time`'s `now` + -- the default `check_validity=True` path being as exposed as the + explicit one; `mining.mine`'s `max_tries` *and* its header, which + `dataclasses.replace` used to complain about; `next_bits`'s two + datetimes; `retarget_first_height`; all three numbers of `hash_rate`, + where a difficulty and a timespan are `float` and an integer is one of + those; `hashes.merkle_root_from_branch`'s leaf index, which is what + `merkle_proof.assert_as_valid` and `merkle_proof.verify` reach it + through; and `utils.encode_num`. + - **Every field a sig_hash preimage writes is checked as a width, and every `vin_i` as an index** (issue #744). `int.to_bytes` answers a field too wide for it with an `OverflowError`, which is an diff --git a/HISTORY.md b/HISTORY.md index d5574cfc9..e592a6b7e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -33,6 +33,29 @@ full year, short month, short day (YYYY-M-D) spaces around it -- `"MAINNET"` to `b32.address_from_witness` -- is accepted now where it used to raise. +- **a malformed argument raises a btclib error, not a native one.** The + rest of issue #744's census, and the same move the network name above + made: an out-of-range derivation index was an `OverflowError`, an + out-of-range `vin_i` an `IndexError`, a header timestamp that is no + datetime an `AttributeError`, a hex string that is not one a bare + `ValueError`. Each is a `BTClibValueError` or a `BTClibTypeError` now. + Code catching `ValueError` or `TypeError` keeps working, the two + deriving from those; code catching `OverflowError`, `IndexError` or + `AttributeError` around one of these has to catch `BTClibException`, + or one of the two builtins, instead. + + Two of them are not a narrowing, and are what to act on. What is + neither `bytes` nor a hex `str` is a `BTClibTypeError` where + `bytes_from_octets` used to return it untouched -- a tuple of 33 ints + passed `taproot.assert_valid_control_block` as a control block size, + and `bin_str_entropy_from_entropy(())` was reported as zero bits. And + a dozen calls that answered a malformed argument with a *number* + refuse it: `sig_hash.taproot` with an `input_index` past the end of + the vin, `taproot.input_script_sig` with a negative leaf index, + `bech32.encode` with a negative digit, `mod_inv` with a float, + `int_from_json_number` with 1.5, `Psbt.weight_estimate` on an + incoherent psbt. CHANGELOG.md lists all twelve. + - **the individual point multiplications are private.** `from btclib.curves.curve_group import mult_jac` is an `ImportError` now, and so is every other variant of `curve_group` and `curve_group_2`: the diff --git a/btclib/block/block.py b/btclib/block/block.py index 1f09aed7e..8bcbfb13c 100644 --- a/btclib/block/block.py +++ b/btclib/block/block.py @@ -22,7 +22,7 @@ WITNESS_SCALE_FACTOR, ) from btclib.block.proof_of_work import MAINNET_POW_LIMIT_BITS -from btclib.exceptions import BTClibValueError +from btclib.exceptions import BTClibTypeError, BTClibValueError from btclib.hashes import ( hash256, merkle_root_and_mutated, @@ -31,7 +31,12 @@ from btclib.script.script import op_int from btclib.script.script import serialize as serialize_script from btclib.tx import Tx -from btclib.utils import assert_no_trailing, bytesio_from_binarydata, decode_num +from btclib.utils import ( + assert_no_trailing, + bytesio_from_binarydata, + decode_num, + is_integer, +) __all__ = [ "Block", @@ -86,6 +91,12 @@ def bip34_commitment(height: int) -> bytes: binds first and every node on such a chain compares against the op code. """ + # before the comparison below, which is what a non-number reaches: + # `-1 <= "5"` is a bare TypeError about the operands rather than + # about the height, and `True` is the height one + if not is_integer(height): + raise BTClibTypeError(f"invalid height type: {type(height).__name__}") + # Core's push_int64 has three branches -- OP_0 for zero, OP_1..OP_16 # for 1 to 16, OP_1NEGATE for -1 -- and op_int names all three command: Command = op_int(height) if -1 <= height <= 16 else height diff --git a/btclib/block/block_header.py b/btclib/block/block_header.py index 1fbf35343..a19d3afd6 100644 --- a/btclib/block/block_header.py +++ b/btclib/block/block_header.py @@ -283,6 +283,11 @@ def assert_valid_time(self, now: datetime) -> None: one, and it stays out of reach: it is the median time past of eleven ancestors, i.e. the chain. """ + # the type before the time zone: `.tzinfo` on anything else is an + # AttributeError, which is neither a ValueError nor a TypeError + # and so is caught by nothing this library tells a caller to catch + if not isinstance(now, datetime): + raise BTClibTypeError(f"invalid current time type: {type(now).__name__}") if now.tzinfo is None or now.tzinfo.utcoffset(now) is None: raise BTClibValueError(f"naive current time (no time zone): {now}") @@ -296,6 +301,35 @@ def assert_valid_time(self, now: datetime) -> None: err_msg += f" > {now} + {MAX_FUTURE_BLOCK_TIME} seconds" raise BTClibValueError(err_msg) + def _assert_valid_types(self) -> None: + """Refuse a field the eighty bytes could not be built from. + + The type check the bytes fields get from bytes() below, for the + three that get none. Not a coercion, which would repair the + mistake by rewriting the header being inspected (__init__ + coerces, this reports); not dropped either, which would let a + float reach to_bytes and leave the library through an + AttributeError, and a str reach `.tzinfo` and leave through + another one -- neither of them a half of this library's + exception contract. + + The timestamp is the one field __init__ coerces nothing into: a + moment has no single spelling to coerce from, + `datetime.fromtimestamp` needing a time zone the caller has not + given. + """ + for key in ("version", "nonce"): + value = getattr(self, key) + if not is_integer(value): + err_msg = f"invalid {key} type: {type(value).__name__}" + raise BTClibTypeError(err_msg) + + if not isinstance(self.time, datetime): + # unreachable to mypy, the field being annotated: the caller + # this is here for is the one mypy never sees + err_msg = f"invalid timestamp type: {type(self.time).__name__}" # type: ignore[unreachable] + raise BTClibTypeError(err_msg) + def assert_valid(self) -> None: """Refuse a header the eighty bytes could not hold. @@ -305,16 +339,7 @@ def assert_valid(self) -> None: are assert_valid_time and assert_valid_pow, whose docstrings say why they are separate. """ - # the type check the bytes fields get from bytes() below, for the - # two int ones. Not a coercion, which would repair the mistake by - # rewriting the header being inspected (__init__ coerces, this - # reports); not dropped either, which would let a float reach - # to_bytes and leave the library through an AttributeError - for key in ("version", "nonce"): - value = getattr(self, key) - if not is_integer(value): - err_msg = f"invalid {key} type: {type(value).__name__}" - raise BTClibTypeError(err_msg) + self._assert_valid_types() # must be a 4-bytes _signed_ integer if not 0 < self.version <= 0x7FFFFFFF: diff --git a/btclib/block/mining.py b/btclib/block/mining.py index 4c00436cf..c32f7cb33 100644 --- a/btclib/block/mining.py +++ b/btclib/block/mining.py @@ -28,8 +28,9 @@ from btclib.alias import Octets from btclib.block.block import merkle_root_and_mutated_from_transactions from btclib.block.block_header import BlockHeader -from btclib.exceptions import BTClibValueError +from btclib.exceptions import BTClibTypeError, BTClibValueError from btclib.tx import Tx +from btclib.utils import is_integer __all__ = [ "NONCE_SPACE", @@ -101,6 +102,14 @@ def mine(header: BlockHeader, max_tries: int = 1 << 20) -> BlockHeader | None: The caller's header is left alone: what comes back is a copy. """ + # `replace()` says "should be called on dataclass instances" for + # anything else, from the standard library and about a call the caller + # never made; `max_tries < 1` is a bare TypeError about the operands, + # and a float passes it to fail at `range` a few lines down + if not isinstance(header, BlockHeader): + raise BTClibTypeError(f"invalid header type: {type(header).__name__}") + if not is_integer(max_tries): + raise BTClibTypeError(f"invalid max_tries type: {type(max_tries).__name__}") if max_tries < 1: raise BTClibValueError(f"invalid max_tries: {max_tries}") diff --git a/btclib/block/proof_of_work.py b/btclib/block/proof_of_work.py index 1d5785228..450eaf535 100644 --- a/btclib/block/proof_of_work.py +++ b/btclib/block/proof_of_work.py @@ -31,8 +31,8 @@ from datetime import datetime from btclib.alias import Octets -from btclib.exceptions import BTClibValueError -from btclib.utils import bytes_from_octets +from btclib.exceptions import BTClibTypeError, BTClibValueError +from btclib.utils import bytes_from_octets, is_integer __all__ = [ "DIFFICULTY_ADJUSTMENT_INTERVAL", @@ -213,6 +213,11 @@ def retarget_first_height(last_height: int) -> int: error, so `GetNextWorkRequired` still reads `nHeight - (DifficultyAdjustmentInterval() - 1)`. """ + # a height, before the arithmetic: `"2015" + 1` is a bare TypeError + # about concatenating a str, and `True + 1` is the height two + if not is_integer(last_height): + raise BTClibTypeError(f"invalid height type: {type(last_height).__name__}") + # Core only retargets when the *next* height is a multiple of 2016, # so the last block of a period is the one 2015 blocks after its # first. Refused rather than answered for any other height: the @@ -251,6 +256,17 @@ def next_bits( Bitcoin Core spells this `CalculateNextWorkRequired`. """ + # both are datetimes, checked before the subtraction: two ints + # subtract to an int and answer `AttributeError: 'int' object has no + # attribute 'total_seconds'`, which is neither half of this library's + # exception contract, and a str answers a TypeError about the operands + for name, value in ( + ("first block time", first_block_time), + ("last block time", last_block_time), + ): + if not isinstance(value, datetime): + raise BTClibTypeError(f"invalid {name} type: {type(value).__name__}") + # the difference of two datetimes, not two timestamp() calls: aware # or naive, the subtraction is the same number of seconds, so the # answer does not depend on the machine's time zone for the naive @@ -344,6 +360,18 @@ def hash_rate(difficulty: float, timespan: float, block_count: int = 1) -> float with to within the 1/65536 by which the genesis target falls short of 2^224. """ + # the types before the three comparisons, each of which is a bare + # TypeError about the operands for anything that is not a number. A + # difficulty and a timespan are `float` here and an integer is one of + # those, `1.0` and `1` being the same rate; a block count is a count, + # so it is the narrower question -- and a bool is neither, `True` + # being one block, one second and difficulty one + for name, value in (("timespan", timespan), ("difficulty", difficulty)): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise BTClibTypeError(f"invalid {name} type: {type(value).__name__}") + if not is_integer(block_count): + raise BTClibTypeError(f"invalid block count type: {type(block_count).__name__}") + if timespan <= 0: raise BTClibValueError(f"invalid timespan: {timespan}") if block_count < 1: diff --git a/btclib/ecc/borromean.py b/btclib/ecc/borromean.py index ec581a114..9387c36fc 100644 --- a/btclib/ecc/borromean.py +++ b/btclib/ecc/borromean.py @@ -21,7 +21,7 @@ from btclib.alias import HashF, Octets, Point from btclib.curves import Curve, bytes_from_point, double_mult, mult, secp256k1 -from btclib.exceptions import BTClibRuntimeError +from btclib.exceptions import BTClibRuntimeError, BTClibValueError from btclib.utils import bytes_from_octets, int_from_bits __all__ = [ @@ -107,15 +107,20 @@ def sign( for pubk_ring in pubk_rings ] + # one entry per ring in each of the three, checked here rather than + # left to the `strict=True` of the two loops below: a short ks would + # truncate them silently and sign a subset of the rings -- a signature + # over fewer rings than the caller asked for, which is the one thing a + # ring signature must not do quietly. zip's own message is a + # BTClibValueError's class with none of its content, naming "argument + # 3" and no parameter of this function; strict=True stays, as the + # assertion that this check and those loops cannot drift apart + if not len(pubk_rings) == len(sign_key_idx) == len(ks): + err_msg = f"{len(pubk_rings)} rings, {len(sign_key_idx)} signing indexes" + err_msg += f" and {len(ks)} nonces" + raise BTClibValueError(err_msg) + # step 1 - # strict=True, and it is the one zip in this package that changes what - # an argument does rather than documenting an invariant already checked: - # nothing validates that ks, sign_key_idx and pubk_rings have one entry - # per ring, so a short ks would truncate the loop silently and sign a - # subset of the rings -- a signature over fewer rings than the caller - # asked for, which is the one thing a ring signature must not do - # quietly. ValueError, and BTClibValueError is a ValueError, so a caller - # already catching this package's errors catches it for i, (pubk_ring, j_star, k) in enumerate( zip(pubk_rings, sign_key_idx, ks, strict=True) ): diff --git a/btclib/hashes.py b/btclib/hashes.py index 270c28729..04271620d 100644 --- a/btclib/hashes.py +++ b/btclib/hashes.py @@ -18,7 +18,7 @@ from btclib._ripemd160 import ripemd160 as pure_python_ripemd160 from btclib.alias import HashDigestF, HashF, Octets from btclib.exceptions import BTClibTypeError, BTClibValueError -from btclib.utils import bytes_from_octets +from btclib.utils import bytes_from_octets, is_integer __all__ = [ "hash160", @@ -286,6 +286,11 @@ def merkle_root_from_branch( means knowing what a transaction looks like -- a layer this module sits below, and must not import. """ + # the type before the sign, `"0" < 0` being a bare TypeError about + # the operands: an index is a position and a bool is not one, `True` + # naming the second leaf of every tree it is passed to + if not is_integer(index): + raise BTClibTypeError(f"invalid leaf index type: {type(index).__name__}") if index < 0: raise BTClibValueError(f"negative leaf index: {index}") diff --git a/btclib/mnemonic/entropy.py b/btclib/mnemonic/entropy.py index 8469b1c14..c0e74dc3c 100644 --- a/btclib/mnemonic/entropy.py +++ b/btclib/mnemonic/entropy.py @@ -49,6 +49,27 @@ Entropy = BinStr | int | bytes +def _int_from_bin_str(entropy: BinStr) -> int: + """Return the number a binary 0/1 string spells, or refuse the string. + + `int(x, 2)` answers what is no binary string with the bare + ValueError "invalid literal for int() with base 2", which names + neither the parameter nor this library, and what is no string at all + with a bare TypeError. + + Neither message carries the value, and neither does this one: raw + entropy is seed material, and every error in this module says a + length or a count and never the digits (issue #137). + """ + try: + return int(entropy, 2) + except TypeError as e: + err_msg = f"invalid entropy type: {type(entropy).__name__}" + raise BTClibTypeError(err_msg) from e + except ValueError as e: + raise BTClibValueError("invalid entropy: not a binary 0/1 string") from e + + def wordlist_indexes_from_bin_str_entropy(entropy: BinStr, base: int) -> list[int]: """Return the digit indexes for the provided raw entropy. @@ -57,7 +78,7 @@ def wordlist_indexes_from_bin_str_entropy(entropy: BinStr, base: int) -> list[in entropy; leading zeros are not considered redundant padding. """ bits = len(entropy) - int_entropy = int(entropy, 2) + int_entropy = _int_from_bin_str(entropy) indexes = [] while int_entropy: int_entropy, index = divmod(int_entropy, base) @@ -175,7 +196,7 @@ def bytes_entropy_from_str(bin_str_entropy: BinStr) -> bytes: err_msg = f"invalid number of bits: {n_bits} instead of {_bits}" raise BTClibValueError(err_msg) nbytes = (n_bits + 7) // 8 - int_entropy = int(bin_str_entropy, 2) + int_entropy = _int_from_bin_str(bin_str_entropy) return int_entropy.to_bytes(nbytes, byteorder="big", signed=False) @@ -196,11 +217,18 @@ def bin_str_entropy_from_int( if isinstance(int_entropy, str): int_entropy = int_entropy.strip().lower() if int_entropy[:2] == "0b": - int_entropy = int(int_entropy, 2) - elif int_entropy[:2] == "0x": - int_entropy = int(int_entropy, 16) + int_entropy = _int_from_bin_str(int_entropy) else: - int_entropy = int(int_entropy) + # the two `int` readings left, and the same bare ValueError + # out of both: "invalid literal for int() with base 16", + # naming neither the parameter nor this library -- and + # carrying the digits, which the message here does not + base = 16 if int_entropy[:2] == "0x" else 10 + try: + int_entropy = int(int_entropy, base) + except ValueError as e: + err_msg = f"invalid entropy: not a base {base} number" + raise BTClibValueError(err_msg) from e if int_entropy < 0: raise BTClibValueError(f"negative entropy: {int_entropy}") @@ -234,7 +262,7 @@ def bin_str_entropy_from_str(str_entropy: str, bits: OneOrMoreInt = _bits) -> Bi Default bit-sizes are 128, 160, 192, 224, 256, or 512 bits. """ - int(str_entropy, 2) + _int_from_bin_str(str_entropy) # if a single int, make it a tuple if isinstance(bits, int): @@ -372,7 +400,7 @@ def bin_str_entropy_from_random( if len(entropy) > bits: # only the leftmost bits are retained entropy = entropy[:bits] - i = int(entropy, 2) + i = _int_from_bin_str(entropy) # XOR the current entropy with CSPRNG system entropy i ^= secrets.randbits(bits) diff --git a/btclib/to_prv_key.py b/btclib/to_prv_key.py index b3fa003f1..beffd53fe 100644 --- a/btclib/to_prv_key.py +++ b/btclib/to_prv_key.py @@ -75,7 +75,10 @@ def int_from_prv_key(prv_key: PrvKey, ec: Curve = secp256k1) -> int: try: prv_key = bytes_from_octets(prv_key, ec.n_size) q = int.from_bytes(prv_key, "big") - except ValueError as e: + # both, as `to_pub_key` catches both here: what is neither octets + # nor a spelling of them is a TypeError, and it means the same + # thing as a wrong size does -- this input is not a private key + except (TypeError, ValueError) as e: # never echo the input: it is candidate key material. What the # reasons carry is why each format rejected it -- a checksum, a # prefix, a size -- none of which is secret @@ -313,7 +316,7 @@ def prv_keyinfo_from_prv_key( try: prv_key = bytes_from_octets(prv_key, ec.n_size) q = int.from_bytes(prv_key, byteorder="big", signed=False) - except ValueError as e: + except (TypeError, ValueError) as e: # never echo the input: it is candidate key material. The # reasons say why each format rejected it, and none of them is # secret diff --git a/btclib/utils.py b/btclib/utils.py index b32f72929..27dff2ddd 100644 --- a/btclib/utils.py +++ b/btclib/utils.py @@ -86,7 +86,29 @@ def bytes_from_octets(octets: Octets, out_size: NoneOneOrMoreInt = None) -> byte and say it had checked a size. """ if isinstance(octets, str): # hex string - octets = bytes.fromhex(octets) + # `bytes.fromhex` raises a bare ValueError -- the same class the + # contract promises, so what was lost is only that it came from + # here. This is the one coercion every `Octets` parameter of the + # library runs through, so it is the one place worth saying it in. + # The message is fromhex's own, which names a position and never + # the string: an Octets parameter is candidate key material as + # often as not, and `to_prv_key` puts this very message inside its + # own "not a private key" (issue #137) + try: + octets = bytes.fromhex(octets) + except ValueError as e: + raise BTClibValueError(f"invalid hex string: {e}") from e + elif not isinstance(octets, (bytes, bytearray, memoryview)): + # what is neither went through untouched and reached whatever the + # caller went on to do with it: `len` of a tuple of 33 ints is 33, + # so `taproot.assert_valid_control_block` accepted one as a + # control block size. + # Every buffer and not `bytes` alone, and returned as it came: + # `assert_valid` is a read and must not rewrite the field it + # reads, which is what `bytes()` here would do to a bytearray a + # caller built (`tests/bip32/bip32_test.py` pins it) + err_msg = f"invalid octets type: {type(octets).__name__}" # type: ignore[unreachable] + raise BTClibTypeError(err_msg) if out_size is None: return octets @@ -290,11 +312,16 @@ def int_from_integer(i: Integer) -> int: if isinstance(i, str): i = i.strip().lower() if i.startswith(("0x", "-0x")): - return int(i, 16) - i = bytes.fromhex(i) + # the same bare ValueError bytes_from_octets names below, out + # of the one spelling that does not reach it + try: + return int(i, 16) + except ValueError as e: + raise BTClibValueError(f"invalid hex integer: {i!r}") from e - # must be bytes - return int.from_bytes(i, "big", signed=False) + # the hex string, and the refusal of what is neither that nor bytes, + # both being bytes_from_octets's to give + return int.from_bytes(bytes_from_octets(i), "big", signed=False) def hex_string(i: Integer) -> str: @@ -391,6 +418,12 @@ def encode_num(i: int) -> bytes: room for its magnitude in eight, which is what Core's `CScriptNum::serialize` writes for it as well. """ + # before the bound, which is a comparison: `"5" <= 2**63 - 1` is a + # bare TypeError about the operands, from underneath the library + # rather than through its exception contract, and True would be the + # script number one -- the reason `is_integer` names bool + if not is_integer(i): + raise BTClibTypeError(f"non-integer script number: {type(i).__name__}") if not _MIN_SCRIPT_NUM <= i <= _MAX_SCRIPT_NUM: err_msg = f"script number out of range: {i}" err_msg += f", not in [{_MIN_SCRIPT_NUM}, {_MAX_SCRIPT_NUM}]" diff --git a/docs/source/guide.rst b/docs/source/guide.rst index 973aeb8c1..a6de847f4 100644 --- a/docs/source/guide.rst +++ b/docs/source/guide.rst @@ -89,7 +89,7 @@ with ``bytes.fromhex``. Passing text where hex is expected fails: >>> from btclib.ecc import dsa >>> dsa.sign("hello world", 1) Traceback (most recent call last): -ValueError: non-hexadecimal number found in fromhex() arg at position 0 +btclib.exceptions.BTClibValueError: invalid hex string: non-hexadecimal number found in fromhex() arg at position 0 Pass ``bytes`` when you mean text, and let the hex spelling be for things that are bytes: diff --git a/tests/block/block_context_test.py b/tests/block/block_context_test.py index cd2f8a826..361cbd32b 100644 --- a/tests/block/block_context_test.py +++ b/tests/block/block_context_test.py @@ -227,6 +227,13 @@ def test_a_timestamp_may_be_two_hours_ahead_and_no_more() -> None: with pytest.raises(BTClibValueError, match=err_msg): header.assert_valid_time(header.time.replace(tzinfo=None)) + # and what is no datetime at all reaches `.tzinfo` no longer: an + # AttributeError is neither a ValueError nor a TypeError, so nothing + # this library tells a caller to catch would have caught it + for not_a_datetime in ("nope", 12345, None, header.time.date()): + with pytest.raises(BTClibTypeError, match="invalid current time type: "): + header.assert_valid_time(not_a_datetime) # type: ignore[arg-type] + def test_the_contextual_rules_are_not_asked_by_assert_valid() -> None: """Two questions, and a block answers the first one on its own. diff --git a/tests/block/block_test.py b/tests/block/block_test.py index 78d4b1922..d4ca05967 100644 --- a/tests/block/block_test.py +++ b/tests/block/block_test.py @@ -183,6 +183,16 @@ def test_exceptions() -> None: with pytest.raises(BTClibValueError, match=err_msg): header.assert_valid() + # and what is no datetime at all is refused before `.tzinfo` is asked + # of it: an AttributeError is outside both halves of this library's + # exception contract, and the default `check_validity=True` path was + # as exposed as this one + for not_a_datetime in ("not-a-datetime", 12345, None): + header = BlockHeader.parse(header_bytes) + header.time = not_a_datetime # type: ignore[assignment] + with pytest.raises(BTClibTypeError, match="invalid timestamp type: "): + header.assert_valid() + header = BlockHeader.parse(header_bytes) header.nonce = 0x100000000 with pytest.raises(BTClibValueError, match="invalid nonce: "): diff --git a/tests/block/mining_test.py b/tests/block/mining_test.py index 04a7f2508..c5886a662 100644 --- a/tests/block/mining_test.py +++ b/tests/block/mining_test.py @@ -30,7 +30,7 @@ from btclib.block import Block, BlockHeader from btclib.block.mining import VERSION, candidate_block_header, mine from btclib.block.proof_of_work import REGTEST_POW_LIMIT_BITS -from btclib.exceptions import BTClibValueError +from btclib.exceptions import BTClibTypeError, BTClibValueError from btclib.script import ScriptPubKey from btclib.tx import OutPoint, Tx, TxIn, TxOut from btclib.utils import encode_num @@ -204,6 +204,25 @@ def test_mine_exceptions() -> None: mine(candidate) +def test_mine_refuses_what_is_no_header_and_no_count() -> None: + """`replace()` complained about dataclasses, from the standard library. + + "should be called on dataclass instances", raised by `dataclasses` + about a call the caller never made -- and `max_tries < 1` was a bare + TypeError about the operands, with a float passing it to fail at + `range` a few lines further down. + """ + transactions = [_coinbase(1)] + candidate = candidate_block_header(_PREVIOUS, transactions, _TIME, _EASY_BITS) + + for not_a_header in ("not a header", None, 1): + with pytest.raises(BTClibTypeError, match="invalid header type: "): + mine(not_a_header) # type: ignore[arg-type] + for not_a_count in ("lots", None, 1.5): + with pytest.raises(BTClibTypeError, match="invalid max_tries type: "): + mine(candidate, not_a_count) # type: ignore[arg-type] + + def test_candidate_version() -> None: """BIP9 leaves the top three bits at 001, and version 1 is dead.""" assert VERSION == 0x20000000 diff --git a/tests/block/proof_of_work_test.py b/tests/block/proof_of_work_test.py index 20c8d5a7d..e777d8fe5 100644 --- a/tests/block/proof_of_work_test.py +++ b/tests/block/proof_of_work_test.py @@ -36,7 +36,7 @@ retarget_first_height, target_from_bits, ) -from btclib.exceptions import BTClibValueError +from btclib.exceptions import BTClibTypeError, BTClibValueError def _time(timestamp: int) -> datetime: @@ -314,6 +314,31 @@ def test_retarget_first_height() -> None: with pytest.raises(BTClibValueError, match="invalid retarget height: "): retarget_first_height(last) + # and what is no height at all is refused before the arithmetic: + # `"2015" + 1` complained about concatenating a str to an int + for not_a_height in ("2015", None, 1.5): + with pytest.raises(BTClibTypeError, match="invalid height type: "): + retarget_first_height(not_a_height) # type: ignore[arg-type] + + +def test_next_bits_takes_two_datetimes_and_says_so() -> None: + """Two ints subtract to an int, which has no `total_seconds`. + + So the timespan of a retarget window given as two unix timestamps -- + the spelling a caller reading a header's own field would reach for -- + left through an `AttributeError`, which is neither half of this + library's exception contract; a str left through a TypeError about + the operands of a subtraction. + """ + first, last = _time(1279008237), _time(1279297671) + assert next_bits("1c05a3f4", first, last) == bytes.fromhex("1c0168fd") + + for not_a_time in ("nope", 1279008237, None, first.date()): + with pytest.raises(BTClibTypeError, match="invalid first block time type: "): + next_bits("1c05a3f4", not_a_time, last) # type: ignore[arg-type] + with pytest.raises(BTClibTypeError, match="invalid last block time type: "): + next_bits("1c05a3f4", first, not_a_time) # type: ignore[arg-type] + def test_next_bits_mainnet_history() -> None: """The four retarget vectors of Core's `pow_tests.cpp`. diff --git a/tests/ecc/borromean_test.py b/tests/ecc/borromean_test.py index fb045116f..e7027dad3 100644 --- a/tests/ecc/borromean_test.py +++ b/tests/ecc/borromean_test.py @@ -14,7 +14,11 @@ from btclib.alias import Point from btclib.curves import mult, secp256k1 from btclib.ecc import borromean, dsa -from btclib.exceptions import BTClibRuntimeError, BTClibValueError +from btclib.exceptions import ( + BTClibRuntimeError, + BTClibTypeError, + BTClibValueError, +) from tests.curves.curve_test import low_card_curves @@ -46,7 +50,7 @@ def test_borromean() -> None: # a msg that is neither bytes nor a hex-str is a caller error, and # verify says so instead of answering False: catching Exception would # report an int msg as a failed ring signature - with pytest.raises(TypeError): + with pytest.raises(BTClibTypeError, match="invalid octets type: int"): borromean.verify(0, sig[0], sig[1], pubk_rings) # type: ignore[arg-type] # a forged signature must raise, not merely return a falsy value: @@ -212,3 +216,30 @@ def test_the_point_at_infinity_is_the_other_corner_case() -> None: assert not borromean.verify( b"\x00\x00\x00\x00", (21).to_bytes(32, "big"), [[1]], [[Q1]], ec=ec ) + + +def test_one_nonce_and_one_signing_index_per_ring() -> None: + """A short ks truncated the loops and signed a subset of the rings. + + `zip(..., strict=True)` is what caught it, with the message "zip() + argument 3 is shorter than argument 1" -- a `BTClibValueError`'s + class carrying none of its content, naming an argument position of + `zip` and no parameter of `sign`. The check is `sign`'s own now, and + `strict=True` stays as the assertion that the two cannot drift + apart. + """ + ring_sizes = [3, 4] + sign_key_idx = [2, 1] + key_rings = [[dsa.gen_keys() for _ in range(size)] for size in ring_sizes] + sign_keys = [key_rings[i][sign_key_idx[i]][0] for i in range(2)] + pubk_rings = [[key_rings[i][j][1] for j in range(ring_sizes[i])] for i in range(2)] + msg = b"Borromean ring signature" + + assert borromean.sign(msg, [1, 2], sign_key_idx, sign_keys, pubk_rings) + + err_msg = "2 rings, 2 signing indexes and 1 nonces" + with pytest.raises(BTClibValueError, match=err_msg): + borromean.sign(msg, [1], sign_key_idx, sign_keys, pubk_rings) + err_msg = "2 rings, 1 signing indexes and 2 nonces" + with pytest.raises(BTClibValueError, match=err_msg): + borromean.sign(msg, [1, 2], sign_key_idx[:1], sign_keys, pubk_rings) diff --git a/tests/ecc/der_test.py b/tests/ecc/der_test.py index 68a8760e7..289ca6144 100644 --- a/tests/ecc/der_test.py +++ b/tests/ecc/der_test.py @@ -37,8 +37,8 @@ def test_der_size() -> None: def test_der_deserialize() -> None: """Refuse each malformed DER field with its own message.""" - err_msg = "non-hexadecimal number found " - with pytest.raises(ValueError, match=err_msg): + err_msg = "invalid hex string: non-hexadecimal number found " + with pytest.raises(BTClibValueError, match=err_msg): Sig.parse("not a sig") sig = Sig(2**255 - 4, 2**247 - 1) diff --git a/tests/fetch/fetcher_test.py b/tests/fetch/fetcher_test.py index dc943eb99..fef899d58 100644 --- a/tests/fetch/fetcher_test.py +++ b/tests/fetch/fetcher_test.py @@ -75,7 +75,7 @@ def test_tx_id_hex_takes_whatever_octets_takes() -> None: @pytest.mark.parametrize("tx_id", ["", "00", TX_ID + "00", "not hex at all"]) def test_tx_id_hex_refuses_what_is_not_an_id(tx_id: str) -> None: """A mistyped id is the caller's error, not the remote host's 404.""" - with pytest.raises(ValueError): + with pytest.raises(BTClibValueError): tx_id_hex(tx_id) diff --git a/tests/integer_policy_test.py b/tests/integer_policy_test.py index 8ebcd7b15..06b22f8d5 100644 --- a/tests/integer_policy_test.py +++ b/tests/integer_policy_test.py @@ -32,14 +32,18 @@ str_from_index_int, ) from btclib.block import BlockHeader +from btclib.block.block import bip34_commitment from btclib.block.block_context import BlockContext +from btclib.block.mining import mine +from btclib.block.proof_of_work import hash_rate, retarget_first_height from btclib.exceptions import BTClibTypeError from btclib.fee import FeeRate, fee_from_vsize +from btclib.hashes import merkle_root_from_branch, sha256 from btclib.mnemonic.entropy import bin_str_entropy_from_wordlist_indexes from btclib.number_theory import mod_inv from btclib.script import input_script_sig, sig_hash from btclib.tx import OutPoint, Tx, TxIn, TxOut -from btclib.utils import bytes_from_octets, is_integer +from btclib.utils import bytes_from_octets, encode_num, is_integer _TX_ID = "01" * 32 _RATE = FeeRate(sats_per_kvbyte=1000) @@ -121,6 +125,17 @@ def _header(version: Any = 1, nonce: Any = 1) -> BlockHeader: "sig_hash input index", lambda v: sig_hash.taproot(_tx(), v, _PREVOUTS, 1, 0, b"", b""), ), + ("script number", encode_num), + ( + "merkle leaf index", + lambda v: merkle_root_from_branch(b"\x00" * 32, [], v, sha256), + ), + ("bip34 commitment height", bip34_commitment), + ("retarget height", retarget_first_height), + ("mining max tries", lambda v: mine(_header(), v)), + ("hash rate difficulty", lambda v: hash_rate(v, 600.0)), + ("hash rate timespan", lambda v: hash_rate(1.0, v)), + ("hash rate block count", lambda v: hash_rate(1.0, 600.0, v)), ] _IDS = [case[0] for case in _CASES] @@ -174,6 +189,15 @@ def test_the_integers_a_bool_refusal_must_not_take_with_it() -> None: assert mod_inv(3, 7) == 5 assert input_script_sig(None, _SCRIPT_TREE, 0)[0] == ["OP_1"] assert len(sig_hash.taproot(_tx(), 0, _PREVOUTS, 1, 0, b"", b"")) == 32 + assert encode_num(1) == b"\x01" + assert merkle_root_from_branch(b"\x00" * 32, [], 0, sha256) == b"\x00" * 32 + assert bip34_commitment(1) == b"Q" + assert retarget_first_height(2015) == 0 + assert mine(_header(), 1) is None + assert hash_rate(1.0, 600.0, 1) == 2**32 / 600.0 + # an integer difficulty and an integer timespan are numbers too: the + # bool refusal must not take the int with it where a float is annotated + assert hash_rate(1, 600) == hash_rate(1.0, 600.0) # the str and bytes spellings of a path are untouched by any of it assert indexes_from_der_path("m/44h/0h") == [2147483692, 2147483648] diff --git a/tests/mnemonic/entropy_test.py b/tests/mnemonic/entropy_test.py index 9c9052d2f..b57cc3a9e 100644 --- a/tests/mnemonic/entropy_test.py +++ b/tests/mnemonic/entropy_test.py @@ -121,7 +121,7 @@ def test_conversions() -> None: # `== "0x"` weakened to `>=` is what a string of only letters tells # apart -- "ab" sorts above "0x" and parses as hex (171) where the # unweakened check falls through to `int("ab")` and raises instead - with pytest.raises(ValueError, match="invalid literal for int"): + with pytest.raises(BTClibValueError, match="not a base 10 number"): bin_str_entropy_from_int("ab", 8) max_bits = max(_bits) @@ -210,13 +210,16 @@ def test_exceptions() -> None: with pytest.raises(BTClibValueError, match=err_msg): bin_str_entropy_from_entropy(bytes_entropy216, 224) - with pytest.raises(BTClibValueError, match=err_msg): + # an empty tuple is entropy of no spelling at all, which is what it + # is refused as: it used to reach `len()` and be reported as zero + # bits, a number it never carried + with pytest.raises(BTClibTypeError, match="invalid octets type: tuple"): bin_str_entropy_from_entropy(()) # type: ignore[arg-type] - with pytest.raises(ValueError): + with pytest.raises(BTClibValueError, match="not a base 10 number"): bin_str_entropy_from_int("not an int") - with pytest.raises(TypeError): + with pytest.raises(BTClibTypeError, match="invalid entropy type: int"): bin_str_entropy_from_str(3) # type: ignore[arg-type] err_msg = "invalid number of bits: " @@ -430,3 +433,24 @@ def test_bin_str_entropy_from_random() -> None: assert len(bin_str_entropy_from_random(512)) == 512 with pytest.raises(BTClibValueError, match=err_msg): bin_str_entropy_from_random(513) + + +def test_what_is_no_binary_string_is_refused_without_being_echoed() -> None: + """`int(x, 2)` said "invalid literal for int() with base 2" and the digits. + + Raw entropy is seed material, so neither the class nor the message + was right: a bare ValueError naming neither the parameter nor this + library, carrying the very string it was handed. Every message in + this module says a length or a count instead. + """ + for not_binary in ("0b2", "0bxyz", "0b"): + with pytest.raises(BTClibValueError, match="not a binary") as excinfo: + bin_str_entropy_from_int(not_binary) + # the digits stay out of the message, being seed material + assert not_binary not in str(excinfo.value) + + for not_binary in ("2" * 128, "abc" * 43): + with pytest.raises(BTClibValueError, match="not a binary 0/1 string"): + bin_str_entropy_from_str(not_binary) + with pytest.raises(BTClibValueError, match="not a binary 0/1 string"): + wordlist_indexes_from_bin_str_entropy(not_binary, 2048) diff --git a/tests/script/sig_hash_taproot_test.py b/tests/script/sig_hash_taproot_test.py index 338f1ff0a..295476ae9 100644 --- a/tests/script/sig_hash_taproot_test.py +++ b/tests/script/sig_hash_taproot_test.py @@ -768,7 +768,7 @@ def sig_hash_with(message_extension: Octets) -> bytes: ) assert sig_hash_with(ext) == sig_hash_with(ext.hex()) - with pytest.raises(ValueError, match="fromhex"): + with pytest.raises(BTClibValueError, match="invalid hex string: "): sig_hash_with("not hex at all") diff --git a/tests/script/taproot_test.py b/tests/script/taproot_test.py index c24347aa3..a02f35e08 100644 --- a/tests/script/taproot_test.py +++ b/tests/script/taproot_test.py @@ -271,12 +271,14 @@ def test_a_control_block_size_is_octets_and_not_characters() -> None: with pytest.raises(BTClibValueError, match=err_msg): assert_valid_control_block(wrong_size) - # a str that is no hex string reaches the size check no longer; the - # class is `bytes_from_octets`'s to tighten, which issue 744's last - # slice is about + # a str that is no hex string reaches the size check no longer, and + # what is no octets at all is refused rather than measured: `len` of + # a tuple of 33 ints is 33 for not_octets in ("é" * 33, "a" * 33): - with pytest.raises(ValueError, match="fromhex"): + with pytest.raises(BTClibValueError, match="invalid hex string: "): assert_valid_control_block(not_octets) + with pytest.raises(BTClibTypeError, match="invalid octets type: tuple"): + assert_valid_control_block(tuple(range(33))) # type: ignore[arg-type] def test_a_leaf_is_named_from_the_start_of_the_tree() -> None: diff --git a/tests/utils_test.py b/tests/utils_test.py index cfef09a37..075346a9b 100644 --- a/tests/utils_test.py +++ b/tests/utils_test.py @@ -12,6 +12,7 @@ from btclib.exceptions import BTClibTypeError, BTClibValueError from btclib.utils import ( assert_no_trailing, + bytes_from_octets, decode_num, encode_num, hex_string, @@ -97,8 +98,9 @@ def test_int_from_integer_reads_a_str_as_hex() -> None: assert int_from_integer(1234) == 1234 # and an odd number of digits is not a one-digit decimal either - # (Python 3.14 rephrased the message bytes.fromhex raises) - with pytest.raises(ValueError, match="fromhex"): + # (the message is bytes.fromhex's own, which Python 3.14 rephrased, + # inside the class this library promises) + with pytest.raises(BTClibValueError, match="invalid hex string: "): int_from_integer("9") @@ -116,7 +118,7 @@ def test_hex_string() -> None: # invalid hex-string: odd number of hex digits # (Python 3.14 rephrased the message bytes.fromhex raises) a_str = "1deadbeef00000000" - with pytest.raises(ValueError, match="fromhex"): + with pytest.raises(BTClibValueError, match="invalid hex string: "): hex_string(a_str) int_ = -1 @@ -250,3 +252,44 @@ def test_a_json_number_is_a_whole_one_or_it_is_an_error() -> None: for not_a_number in (None, object(), [1]): with pytest.raises(BTClibTypeError, match="invalid version type: "): int_from_json_number(not_a_number, "version") + + +def test_octets_are_bytes_or_the_hex_string_of_bytes_and_nothing_else() -> None: + """A tuple went through untouched, to be measured as if it were octets. + + `bytes_from_octets` returned anything that was not a `str` + unchanged, so `len` of a tuple of 33 ints was 33 and + `taproot.assert_valid_control_block` accepted it as a control block + size. Every buffer is still taken, and returned as it came: a read + must not rewrite the field it reads, which is what `bytes()` here + would do to a bytearray a caller built. + """ + assert bytes_from_octets(b"\x00\x01") == b"\x00\x01" + assert bytes_from_octets("0001") == b"\x00\x01" + # every buffer, though `Octets` names only the two spellings a caller + # writes: what reaches this is whatever a field was built from + assert bytes_from_octets(bytearray(b"\x00\x01")) == b"\x00\x01" # type: ignore[arg-type] + assert bytes_from_octets(memoryview(b"\x00\x01")) == b"\x00\x01" # type: ignore[arg-type] + # unchanged, and not merely equal + buffer: object = bytes_from_octets(bytearray(b"\x00")) # type: ignore[arg-type] + assert isinstance(buffer, bytearray) + + for not_octets in (tuple(range(33)), [1, 2], None, 1.5): + with pytest.raises(BTClibTypeError, match="invalid octets type: "): + bytes_from_octets(not_octets) # type: ignore[arg-type] + with pytest.raises(BTClibTypeError, match="invalid octets type: "): + int_from_integer(not_octets) # type: ignore[arg-type] + # an int is an `Integer` and no `Octets`, so the two differ on it + assert int_from_integer(1) == 1 + with pytest.raises(BTClibTypeError, match="invalid octets type: int"): + bytes_from_octets(1) # type: ignore[arg-type] + + # the hex string that is not one, in both, with the message + # `bytes.fromhex` gives: a position, and never the string itself + for not_hex in ("9", "zz", "not hex at all"): + with pytest.raises(BTClibValueError, match="invalid hex string: "): + bytes_from_octets(not_hex) + with pytest.raises(BTClibValueError, match="invalid hex string: "): + int_from_integer(not_hex) + with pytest.raises(BTClibValueError, match="invalid hex integer: "): + int_from_integer("0xzz")