Skip to content

Commit 1d5ed28

Browse files
authored
fix: ensure the timestamp validation checks the upper limit given in the ulid spec (#27)
1 parent 419116f commit 1d5ed28

File tree

5 files changed

+45
-8
lines changed

5 files changed

+45
-8
lines changed

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Changelog
55

66
Versions follow `Semantic Versioning <http://www.semver.org>`_
77

8+
`2.7.0`_ - 2024-06-16
9+
---------------------
10+
Changed
11+
~~~~~~~
12+
* Ensure that the validation of ULID's timestamp component aligns more closely with
13+
the ULID specification.
14+
815
`2.6.0`_ - 2024-05-26
916
---------------------
1017
Changed
@@ -167,6 +174,7 @@ Changed
167174
* The package now has no external dependencies.
168175
* The test-coverage has been raised to 100%.
169176

177+
.. _2.7.0: https://github.com/mdomke/python-ulid/compare/2.6.0...2.7.0
170178
.. _2.6.0: https://github.com/mdomke/python-ulid/compare/2.5.0...2.6.0
171179
.. _2.5.0: https://github.com/mdomke/python-ulid/compare/2.4.0...2.5.0
172180
.. _2.4.0: https://github.com/mdomke/python-ulid/compare/2.3.0...2.4.0

tests/test_base32.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
(base32.decode, "A" * (constants.REPR_LEN + 1)),
2222
(base32.decode_timestamp, "A" * (constants.TIMESTAMP_REPR_LEN - 1)),
2323
(base32.decode_timestamp, "A" * (constants.TIMESTAMP_REPR_LEN + 1)),
24+
(base32.decode_timestamp, "Z" * constants.TIMESTAMP_REPR_LEN),
2425
(base32.decode_randomness, "A" * (constants.RANDOMNESS_REPR_LEN - 1)),
2526
(base32.decode_randomness, "A" * (constants.RANDOMNESS_REPR_LEN + 1)),
2627
],

tests/test_ulid.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,37 @@ def test_ulid_invalid_input(constructor: Callable[[Params], ULID], value: Params
167167
constructor(value)
168168

169169

170+
@pytest.mark.parametrize(
171+
("constructor", "value"),
172+
[
173+
(ULID, b"\x00" * 16),
174+
(ULID.from_timestamp, 0),
175+
(ULID.from_bytes, b"\x00" * 16),
176+
(ULID.from_str, "0" * 26),
177+
(ULID.from_hex, "0" * 32),
178+
(ULID.from_uuid, uuid.UUID("0" * 32)),
179+
],
180+
)
181+
def test_ulid_min_input(constructor: Callable[[Params], ULID], value: Params) -> None:
182+
constructor(value)
183+
184+
185+
@pytest.mark.parametrize(
186+
("constructor", "value"),
187+
[
188+
(ULID, b"\xff" * 16),
189+
(ULID.from_timestamp, 281474976710655),
190+
(ULID.from_datetime, datetime.max.replace(tzinfo=timezone.utc)),
191+
(ULID.from_bytes, b"\xff" * 16),
192+
(ULID.from_str, "7" + "Z" * 25),
193+
(ULID.from_hex, "f" * 32),
194+
(ULID.from_uuid, uuid.UUID("f" * 32)),
195+
],
196+
)
197+
def test_ulid_max_input(constructor: Callable[[Params], ULID], value: Params) -> None:
198+
constructor(value)
199+
200+
170201
def test_pydantic_protocol() -> None:
171202
ulid = ULID()
172203

ulid/__init__.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,23 +74,17 @@ class ULID:
7474
7575
Args:
7676
value (bytes, None): A sequence of 16 bytes representing an encoded ULID.
77-
validate (bool): If set to `True` validate if the timestamp part is valid.
7877
7978
Raises:
8079
ValueError: If the provided value is not a valid encoded ULID.
8180
"""
8281

83-
def __init__(self, value: bytes | None = None, validate: bool = True) -> None:
82+
def __init__(self, value: bytes | None = None) -> None:
8483
if value is not None and len(value) != constants.BYTES_LEN:
8584
raise ValueError("ULID has to be exactly 16 bytes long.")
8685
self.bytes: bytes = (
8786
value or ULID.from_timestamp(time.time_ns() // constants.NANOSECS_IN_MILLISECS).bytes
8887
)
89-
if value is not None and validate:
90-
try:
91-
self.datetime # noqa: B018
92-
except ValueError as err:
93-
raise ValueError("ULID timestamp is out of range.") from err
9488

9589
@classmethod
9690
@validate_type(datetime)
@@ -137,7 +131,7 @@ def from_uuid(cls: type[U], value: uuid.UUID) -> U:
137131
>>> ULID.from_uuid(uuid4())
138132
ULID(27Q506DP7E9YNRXA0XVD8Z5YSG)
139133
"""
140-
return cls(value.bytes, validate=False)
134+
return cls(value.bytes)
141135

142136
@classmethod
143137
@validate_type(bytes)

ulid/base32.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ def decode_timestamp(encoded: str) -> bytes:
210210
raise ValueError("ULID timestamp has to be exactly 10 characters long.")
211211
lut = DECODE
212212
values: bytes = bytes(encoded, "ascii")
213+
# https://github.com/ulid/spec?tab=readme-ov-file#overflow-errors-when-parsing-base32-strings
214+
if lut[values[0]] > 7:
215+
raise ValueError(f"Timestamp value {encoded} is too large and will overflow 128-bits.")
213216
return bytes(
214217
[
215218
((lut[values[0]] << 5) | lut[values[1]]) & 0xFF,

0 commit comments

Comments
 (0)