Skip to content

Commit e25f438

Browse files
committed
Stricter validation for user provided ULID values
1 parent 76d5740 commit e25f438

File tree

4 files changed

+36
-11
lines changed

4 files changed

+36
-11
lines changed

CHANGELOG.rst

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

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

8+
`2.6.0`_ - 2024-05-26
9+
---------------------
10+
Changed
11+
~~~~~~~
12+
* Provide more sophisticated validation when creating ``ULID``s from user input. When using
13+
``ULID.from_str`` we will check if the characters match the base32 alphabet. In general, it is
14+
ensured that the timestamp part of the ULID is not out of range.
15+
816
`2.5.0`_ - 2024-04-26
917
---------------------
1018

@@ -159,6 +167,7 @@ Changed
159167
* The package now has no external dependencies.
160168
* The test-coverage has been raised to 100%.
161169

170+
.. _2.6.0: https://github.com/mdomke/python-ulid/compare/2.5.0...2.6.0
162171
.. _2.5.0: https://github.com/mdomke/python-ulid/compare/2.4.0...2.5.0
163172
.. _2.4.0: https://github.com/mdomke/python-ulid/compare/2.3.0...2.4.0
164173
.. _2.3.0: https://github.com/mdomke/python-ulid/compare/2.2.0...2.3.0

tests/test_ulid.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,15 +149,17 @@ def test_ulid_from_timestamp() -> None:
149149
@pytest.mark.parametrize(
150150
("constructor", "value"),
151151
[
152-
(ULID, b"sdf"),
153-
(ULID.from_timestamp, b"not-a-timestamp"),
154-
(ULID.from_datetime, time.time()),
155-
(ULID.from_bytes, b"not-enough"),
156-
(ULID.from_bytes, 123),
157-
(ULID.from_str, "not-enough"),
158-
(ULID.from_str, 123),
159-
(ULID.from_int, "not-an-int"),
160-
(ULID.from_uuid, "not-a-uuid"),
152+
(ULID, b"sdf"), # invalid length
153+
(ULID.from_timestamp, b"not-a-timestamp"), # invalid type
154+
(ULID.from_datetime, time.time()), # invalid type
155+
(ULID.from_bytes, b"not-enough"), # invalid length
156+
(ULID.from_bytes, 123), # invalid type
157+
(ULID.from_str, "not-enough"), # invalid length
158+
(ULID.from_str, 123), # inavlid type
159+
(ULID.from_str, "notavalidulidnotavalidulid"), # invalid alphabet
160+
(ULID.from_str, "Z" * 26), # invalid timestamp
161+
(ULID.from_int, "not-an-int"), # invalid type
162+
(ULID.from_uuid, "not-a-uuid"), # invalid type
161163
],
162164
)
163165
def test_ulid_invalid_input(constructor: Callable[[Params], ULID], value: Params) -> None:

ulid/__init__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,14 +71,26 @@ class ULID:
7171
>>> ulid = ULID()
7272
>>> str(ulid)
7373
'01E75PVKXA3GFABX1M1J9NZZNF'
74+
75+
Args:
76+
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.
78+
79+
Raises:
80+
ValueError: If the provided value is not a valid encoded ULID.
7481
"""
7582

76-
def __init__(self, value: bytes | None = None) -> None:
83+
def __init__(self, value: bytes | None = None, validate: bool = True) -> None:
7784
if value is not None and len(value) != constants.BYTES_LEN:
7885
raise ValueError("ULID has to be exactly 16 bytes long.")
7986
self.bytes: bytes = (
8087
value or ULID.from_timestamp(time.time_ns() // constants.NANOSECS_IN_MILLISECS).bytes
8188
)
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
8294

8395
@classmethod
8496
@validate_type(datetime)
@@ -125,7 +137,7 @@ def from_uuid(cls: type[U], value: uuid.UUID) -> U:
125137
>>> ULID.from_uuid(uuid4())
126138
ULID(27Q506DP7E9YNRXA0XVD8Z5YSG)
127139
"""
128-
return cls(value.bytes)
140+
return cls(value.bytes, validate=False)
129141

130142
@classmethod
131143
@validate_type(bytes)

ulid/base32.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,8 @@ def encode_randomness(binary: bytes) -> str:
198198
def decode(encoded: str) -> bytes:
199199
if len(encoded) != constants.REPR_LEN:
200200
raise ValueError("Encoded ULID has to be exactly 26 characters long.")
201+
if any((c not in ENCODE) for c in encoded):
202+
raise ValueError(f"Encoded ULID can only consist of letters in {ENCODE}.")
201203
return decode_timestamp(encoded[: constants.TIMESTAMP_REPR_LEN]) + decode_randomness(
202204
encoded[constants.TIMESTAMP_REPR_LEN :]
203205
)

0 commit comments

Comments
 (0)