From 86a3987c84806b17720ba7c9364650fd5ff21050 Mon Sep 17 00:00:00 2001 From: Tovellan Maintainers Date: Mon, 24 Aug 2026 03:35:44 +0530 Subject: [PATCH 1/3] Normalize invalid digest inputs --- CHANGELOG.md | 6 ++++++ docs/api.md | 5 +++++ src/splitseal/canonical.py | 37 ++++++++++++++++++++++++++++++++----- tests/test_canonical.py | 29 +++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 945735f..c2887b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes are recorded here. The format follows Keep a Changelog and the project uses Semantic Versioning. +## [Unreleased] + +### Fixed + +- Normalize invalid digest input types and count ranges to stable `SS012` errors. + ## [0.2.3] - 2026-08-24 ### Added diff --git a/docs/api.md b/docs/api.md index f30118d..b875739 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,11 @@ digest = record_digest(record) It rejects non-string object keys, non-finite floats, unsupported objects, and integers outside the exactly interoperable range. +`dataset_digest` accepts string split names paired with a non-negative 64-bit record +count and a lowercase or uppercase hexadecimal SHA-256 digest. `sequence_digest` accepts +a sequence of hexadecimal SHA-256 record digests. Invalid runtime types, encodings, +lengths, and count ranges fail with `SS012`. + ## Release operations ```python diff --git a/src/splitseal/canonical.py b/src/splitseal/canonical.py index bf33e69..1fe5feb 100644 --- a/src/splitseal/canonical.py +++ b/src/splitseal/canonical.py @@ -19,6 +19,8 @@ _SEQUENCE_DOMAIN = b"splitseal-sequence-v1\x00" _DATASET_DOMAIN = b"splitseal-dataset-v1\x00" _MAX_INTEROPERABLE_INTEGER = 9_007_199_254_740_991 +_MAX_RECORD_COUNT = 2**64 - 1 +_SPLIT_DIGEST_ENTRY_SIZE = 2 def _validate_json(value: object, location: str = "$") -> None: @@ -73,8 +75,14 @@ def record_digest(record: Record) -> str: def sequence_digest(record_digests: Sequence[str]) -> str: """Hash an ordered sequence of hexadecimal record digests.""" + if isinstance(record_digests, (str, bytes, bytearray)) or not isinstance( + record_digests, Sequence + ): + raise fail("SS012", "record digests must be a sequence of strings") digest = hashlib.sha256(_SEQUENCE_DOMAIN) for item in record_digests: + if not isinstance(item, str): + raise fail("SS012", "record digest must be a string") try: raw = bytes.fromhex(item) except ValueError as exc: @@ -88,18 +96,37 @@ def sequence_digest(record_digests: Sequence[str]) -> str: def dataset_digest(splits: Mapping[str, tuple[int, str]]) -> str: """Hash named split roots and counts in split-name order.""" + if not isinstance(splits, Mapping): + raise fail("SS012", "dataset splits must be a mapping") + validated: list[tuple[str, int, str]] = [] + for name, value in splits.items(): + if not isinstance(name, str): + raise fail("SS012", "split names must be strings") + if not isinstance(value, tuple) or len(value) != _SPLIT_DIGEST_ENTRY_SIZE: + raise fail("SS012", "split digest entry must be a count and digest pair", split=name) + count, split_digest = value + if type(count) is not int or count < 0 or count > _MAX_RECORD_COUNT: + raise fail( + "SS012", + "record count must be an unsigned 64-bit integer", + split=name, + ) + if not isinstance(split_digest, str): + raise fail("SS012", "split digest must be a string", split=name) + validated.append((name, count, split_digest)) + digest = hashlib.sha256(_DATASET_DOMAIN) - for name in sorted(splits): - count, split_digest = splits[name] - if count < 0: - raise fail("SS012", "record count cannot be negative", split=name) + for name, count, split_digest in sorted(validated, key=lambda item: item[0]): try: raw_digest = bytes.fromhex(split_digest) except ValueError as exc: raise fail("SS012", "split digest is not hexadecimal", split=name) from exc if len(raw_digest) != hashlib.sha256().digest_size: raise fail("SS012", "split digest has an invalid length", split=name) - encoded_name = name.encode("utf-8") + try: + encoded_name = name.encode("utf-8") + except UnicodeEncodeError as exc: + raise fail("SS012", "split name is not valid UTF-8") from exc digest.update(_framed(encoded_name)) digest.update(count.to_bytes(8, "big")) digest.update(_framed(raw_digest)) diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 0a958fa..9908f94 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -69,6 +69,16 @@ def test_sequence_digest_rejects_malformed_digest(digest: str) -> None: assert caught.value.code == "SS012" +@pytest.mark.parametrize( + "digests", + [None, "00" * 32, b"00", [None], [1]], +) +def test_sequence_digest_rejects_wrong_runtime_types(digests: object) -> None: + with pytest.raises(SplitSealError) as caught: + sequence_digest(digests) # type: ignore[arg-type] + assert caught.value.code == "SS012" + + def test_dataset_digest_sorts_split_names_but_includes_counts() -> None: root = record_digest({"id": "one"}) left = dataset_digest({"b": (1, root), "a": (1, root)}) @@ -86,6 +96,25 @@ def test_dataset_digest_rejects_invalid_inputs() -> None: dataset_digest({"a": (1, "00")}) +@pytest.mark.parametrize( + "splits", + [ + None, + {1: (1, "00" * 32)}, + {"a": [1, "00" * 32]}, + {"a": ("1", "00" * 32)}, + {"a": (True, "00" * 32)}, + {"a": (2**64, "00" * 32)}, + {"a": (1, None)}, + {"\ud800": (1, "00" * 32)}, + ], +) +def test_dataset_digest_rejects_wrong_runtime_types(splits: object) -> None: + with pytest.raises(SplitSealError) as caught: + dataset_digest(splits) # type: ignore[arg-type] + assert caught.value.code == "SS012" + + def test_ensure_record_rejects_non_object() -> None: with pytest.raises(SplitSealError) as caught: ensure_record(["not", "an", "object"], location="test") From cc503f99af9cc57bfee58b3af350738bc583d89e Mon Sep 17 00:00:00 2001 From: Tovellan Maintainers Date: Mon, 24 Aug 2026 03:48:39 +0530 Subject: [PATCH 2/3] Require exact hexadecimal digests --- CHANGELOG.md | 1 + docs/api.md | 6 +++--- src/splitseal/canonical.py | 24 ++++++++++++------------ tests/test_canonical.py | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2887b8..bad1fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ project uses Semantic Versioning. ### Fixed - Normalize invalid digest input types and count ranges to stable `SS012` errors. +- Reject whitespace and every other non-exact spelling of 64-character SHA-256 hex. ## [0.2.3] - 2026-08-24 diff --git a/docs/api.md b/docs/api.md index b875739..cbfcbde 100644 --- a/docs/api.md +++ b/docs/api.md @@ -17,9 +17,9 @@ It rejects non-string object keys, non-finite floats, unsupported objects, and i outside the exactly interoperable range. `dataset_digest` accepts string split names paired with a non-negative 64-bit record -count and a lowercase or uppercase hexadecimal SHA-256 digest. `sequence_digest` accepts -a sequence of hexadecimal SHA-256 record digests. Invalid runtime types, encodings, -lengths, and count ranges fail with `SS012`. +count and exactly 64 lowercase or uppercase hexadecimal SHA-256 characters. +`sequence_digest` applies the same exact grammar to each record digest. Whitespace is not +accepted. Invalid runtime types, encodings, lengths, and count ranges fail with `SS012`. ## Release operations diff --git a/src/splitseal/canonical.py b/src/splitseal/canonical.py index 1fe5feb..9fbd4b2 100644 --- a/src/splitseal/canonical.py +++ b/src/splitseal/canonical.py @@ -4,6 +4,7 @@ import hashlib import math +import re from collections.abc import Mapping, Sequence from typing import TypeAlias, cast @@ -21,6 +22,7 @@ _MAX_INTEROPERABLE_INTEGER = 9_007_199_254_740_991 _MAX_RECORD_COUNT = 2**64 - 1 _SPLIT_DIGEST_ENTRY_SIZE = 2 +_SHA256_HEX = re.compile(r"[0-9A-Fa-f]{64}") def _validate_json(value: object, location: str = "$") -> None: @@ -83,12 +85,9 @@ def sequence_digest(record_digests: Sequence[str]) -> str: for item in record_digests: if not isinstance(item, str): raise fail("SS012", "record digest must be a string") - try: - raw = bytes.fromhex(item) - except ValueError as exc: - raise fail("SS012", "record digest is not hexadecimal") from exc - if len(raw) != hashlib.sha256().digest_size: - raise fail("SS012", "record digest has an invalid length") + if not _SHA256_HEX.fullmatch(item): + raise fail("SS012", "record digest must contain exactly 64 hexadecimal characters") + raw = bytes.fromhex(item) digest.update(_framed(raw)) return digest.hexdigest() @@ -117,12 +116,13 @@ def dataset_digest(splits: Mapping[str, tuple[int, str]]) -> str: digest = hashlib.sha256(_DATASET_DOMAIN) for name, count, split_digest in sorted(validated, key=lambda item: item[0]): - try: - raw_digest = bytes.fromhex(split_digest) - except ValueError as exc: - raise fail("SS012", "split digest is not hexadecimal", split=name) from exc - if len(raw_digest) != hashlib.sha256().digest_size: - raise fail("SS012", "split digest has an invalid length", split=name) + if not _SHA256_HEX.fullmatch(split_digest): + raise fail( + "SS012", + "split digest must contain exactly 64 hexadecimal characters", + split=name, + ) + raw_digest = bytes.fromhex(split_digest) try: encoded_name = name.encode("utf-8") except UnicodeEncodeError as exc: diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 9908f94..5ae2e7d 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -79,6 +79,24 @@ def test_sequence_digest_rejects_wrong_runtime_types(digests: object) -> None: assert caught.value.code == "SS012" +@pytest.mark.parametrize( + "digest", + [ + "00 " * 32, + "00" * 16 + "\n" + "00" * 16, + "00" * 16 + "\t" + "00" * 16, + ], +) +def test_digest_functions_reject_ascii_whitespace(digest: str) -> None: + with pytest.raises(SplitSealError) as sequence_error: + sequence_digest([digest]) + assert sequence_error.value.code == "SS012" + + with pytest.raises(SplitSealError) as dataset_error: + dataset_digest({"split": (1, digest)}) + assert dataset_error.value.code == "SS012" + + def test_dataset_digest_sorts_split_names_but_includes_counts() -> None: root = record_digest({"id": "one"}) left = dataset_digest({"b": (1, root), "a": (1, root)}) From 0b973237aafa38384dbae7fd666678f70822a0f8 Mon Sep 17 00:00:00 2001 From: Tovellan Maintainers Date: Mon, 24 Aug 2026 03:49:40 +0530 Subject: [PATCH 3/3] Preserve one-pass digest iterables --- CHANGELOG.md | 1 + docs/api.md | 6 ++++-- src/splitseal/canonical.py | 8 ++++---- tests/test_canonical.py | 5 +++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bad1fda..6cf8b71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ project uses Semantic Versioning. - Normalize invalid digest input types and count ranges to stable `SS012` errors. - Reject whitespace and every other non-exact spelling of 64-character SHA-256 hex. +- Preserve one-pass digest iterables required by bounded-memory release processing. ## [0.2.3] - 2026-08-24 diff --git a/docs/api.md b/docs/api.md index cbfcbde..84a84f5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -18,8 +18,10 @@ outside the exactly interoperable range. `dataset_digest` accepts string split names paired with a non-negative 64-bit record count and exactly 64 lowercase or uppercase hexadecimal SHA-256 characters. -`sequence_digest` applies the same exact grammar to each record digest. Whitespace is not -accepted. Invalid runtime types, encodings, lengths, and count ranges fail with `SS012`. +`sequence_digest` applies the same exact grammar to each record digest from a one-pass +iterable. Text and byte containers are not treated as iterables of digests. Whitespace is +not accepted. Invalid runtime types, encodings, lengths, and count ranges fail with +`SS012`. ## Release operations diff --git a/src/splitseal/canonical.py b/src/splitseal/canonical.py index 9fbd4b2..3ddbb48 100644 --- a/src/splitseal/canonical.py +++ b/src/splitseal/canonical.py @@ -5,7 +5,7 @@ import hashlib import math import re -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping from typing import TypeAlias, cast import rfc8785 @@ -74,13 +74,13 @@ def record_digest(record: Record) -> str: return hashlib.sha256(_RECORD_DOMAIN + _framed(payload)).hexdigest() -def sequence_digest(record_digests: Sequence[str]) -> str: +def sequence_digest(record_digests: Iterable[str]) -> str: """Hash an ordered sequence of hexadecimal record digests.""" if isinstance(record_digests, (str, bytes, bytearray)) or not isinstance( - record_digests, Sequence + record_digests, Iterable ): - raise fail("SS012", "record digests must be a sequence of strings") + raise fail("SS012", "record digests must be an iterable of strings") digest = hashlib.sha256(_SEQUENCE_DOMAIN) for item in record_digests: if not isinstance(item, str): diff --git a/tests/test_canonical.py b/tests/test_canonical.py index 5ae2e7d..0739a1e 100644 --- a/tests/test_canonical.py +++ b/tests/test_canonical.py @@ -62,6 +62,11 @@ def test_sequence_digest_is_order_sensitive() -> None: assert sequence_digest([first, second]) != sequence_digest([second, first]) +def test_sequence_digest_accepts_one_pass_iterables() -> None: + items = [record_digest({"id": "one"}), record_digest({"id": "two"})] + assert sequence_digest(item for item in items) == sequence_digest(items) + + @pytest.mark.parametrize("digest", ["not-hex", "ab"]) def test_sequence_digest_rejects_malformed_digest(digest: str) -> None: with pytest.raises(SplitSealError) as caught: