Repository: ahawker/ulid

Issue:
Add bounds checking for max timestamp overflow case
We need to add validation for handling the max timestamp value, 2 ^ 48 - 1, 281474976710655. Spec notes are at https://github.com/ulid/spec#overflow-errors-when-parsing-base32-strings

Parsing of the `t` value in the following example should raise an exception.
```python
>>> import ulid
>>> s = '7ZZZZZZZZZZZZZZZZZZZZZZZZZ'
>>> t = '8ZZZZZZZZZZZZZZZZZZZZZZZZZ'
>>> ulid.parse(s)
<ULID('7ZZZZZZZZZZZZZZZZZZZZZZZZZ')>
>>> ulid.parse(t)
<ULID('0ZZZZZZZZZZZZZZZZZZZZZZZZZ')>
```

<blockquote><img src="https://avatars1.githubusercontent.com/u/33265518?s=400&v=4" width="48" align="right"><div><img src="https://github.githubassets.com/favicons/favicon.svg" height="14"> GitHub</div><div><strong><a href="https://github.com/ulid/spec">ulid/spec</a></strong></div><div>The canonical spec for ulid. Contribute to ulid/spec development by creating an account on GitHub.</div></blockquote>

Comments:

Patch:
diff --git a/ulid/base32.py b/ulid/base32.py
index 82f450e..83d734d 100644
--- a/ulid/base32.py
+++ b/ulid/base32.py
@@ -366,4 +366,10 @@ def str_to_bytes(value: str, expected_length: int) -> bytes:
         if decoding[byte] > 31:
             raise ValueError('Non-base32 character found: "{}"'.format(chr(byte)))
 
+    # Confirm most significant bit on timestamp value is limited so it can be stored in 128-bits.
+    if length in (10, 26):
+        msb = decoding[encoded[0]]
+        if msb > 7:
+            raise ValueError('Timestamp value too large and will overflow 128-bits. Must be between b"0" and b"7"')
+
     return encoded


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 846861f..5bb6f87 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -16,6 +16,8 @@ from ulid import base32
 ASCII_ALPHABET = ''.join(chr(d) for d in range(0, 128))
 EXTENDED_ASCII_ALPHABET = ''.join(chr(d) for d in range(128, 256))
 ASCII_NON_BASE_32_ALPHABET = ''.join(set(ASCII_ALPHABET).difference(set(base32.ENCODING)))
+MSB_ASCII_ALPHABET = ''.join(chr(d) for d in range(48, 56))
+MSB_ASCII_INVALID_ALPHABET = ''.join(set(base32.ENCODING).difference(set(MSB_ASCII_ALPHABET)))
 
 MIN_EPOCH = 0
 MAX_EPOCH = 281474976710655
@@ -195,6 +197,15 @@ def invalid_str_10(request):
     return random_str(request.param, not_in=[10])
 
 
+@pytest.fixture(scope='function')
+def invalid_str_10_msb_invalid():
+    """
+    Fixture that yields :class:`~str` instances that are 10 characters long and use an invalid
+    MSB.
+    """
+    return random_str(10, msb_alphabet=MSB_ASCII_INVALID_ALPHABET)
+
+
 @pytest.fixture(scope='function', params=range(0, 32))
 def invalid_str_10_16_26(request):
     """
@@ -289,7 +300,7 @@ def random_timestamp_bytes():
     Helper function that returns a number of random bytes that represent a timestamp that are within
     the valid range.
     """
-    value = random.randint(MIN_EPOCH, MAX_EPOCH + 1)
+    value = random.randint(MIN_EPOCH, MAX_EPOCH - 1)
     return value.to_bytes(6, byteorder='big')
 
 
@@ -309,13 +320,13 @@ def random_bytes(num_bytes, not_in=(-1,)):
     return os.urandom(num_bytes)
 
 
-def random_str(num_chars, alphabet=base32.ENCODING, not_in=(-1,)):
+def random_str(num_chars, alphabet=base32.ENCODING, msb_alphabet=MSB_ASCII_ALPHABET, not_in=(-1,)):
     """
     Helper function that returns a string with the specified number of random characters, optionally
     excluding those of a specific length.
     """
     num_chars = num_chars + 1 if num_chars in not_in else num_chars
-    return ''.join(random.choice(alphabet) for _ in range(num_chars))
+    return random.choice(msb_alphabet) + ''.join(random.choice(alphabet) for _ in range(num_chars - 1))
 
 
 def fixed_year_timestamp_bytes(*args, **kwargs):
diff --git a/tests/test_base32.py b/tests/test_base32.py
index 7c069e6..2a4e4a5 100644
--- a/tests/test_base32.py
+++ b/tests/test_base32.py
@@ -18,6 +18,7 @@ DECODE_STR_LEN_EXC_REGEX = r'^Expects string in lengths of'
 DECODE_ULID_STR_LEN_EXC_REGEX = r'^Expects 26 characters for decoding'
 DECODE_TIMESTAMP_STR_LEN_EXC_REGEX = r'^Expects 10 characters for decoding'
 DECODE_RANDOMNESS_STR_LEN_EXC_REGEX = r'^Expects 16 characters for decoding'
+TIMESTAMP_OVERFLOW_EXC_REGEX = r'Timestamp value too large and will overflow 128-bits. Must be between b"0" and b"7"'
 
 
 @pytest.fixture(scope='session')
@@ -355,3 +356,13 @@ def test_str_to_bytes_raises_on_non_base32_decode_char(ascii_non_base32_str_vali
     with pytest.raises(ValueError) as ex:
         base32.str_to_bytes(ascii_non_base32_str_valid_length, len(ascii_non_base32_str_valid_length))
     ex.match(NON_BASE_32_EXC_REGEX)
+
+
+def test_str_to_bytes_raises_on_timestamp_msb_overflow(invalid_str_10_msb_invalid):
+    """
+    Assert that :func:`~ulid.base32.str_to_bytes` raises a :class:`~ValueError` when given a :class:`~str`
+    instance that includes a valid length timestamp but MSB too large causing an overflow.
+    """
+    with pytest.raises(ValueError) as ex:
+        base32.str_to_bytes(invalid_str_10_msb_invalid, len(invalid_str_10_msb_invalid))
+    ex.match(TIMESTAMP_OVERFLOW_EXC_REGEX)