Repository: bbayles/python-pure-cdb (language: python)

Issue:
Value is not found when key's hash == 0
The problem is exactly as stated in the title. Example code that reproduces the problem:
```python
import tempfile
import cdblib
key1 = b'xyz'
key2 = bytes([*[13, 168, 240, 240, 64, 64, 128, 128, 128, 0, 128, 128, 0, 0, 0, 128, 128], *([0]*692791), 128])
value = b'abc'
f = tempfile.TemporaryFile('r+b')
w = cdblib.Writer(f)
w.put(key1, value)
w.put(key2, value)
w.finalize()
f.seek(0)
r = cdblib.Reader(file_obj=f)
print(r.get(key1) == value)
print(r.get(key2) == value)
```
produces the output:
```
True
False
```
It's probably because empty slots are detected in the `gets` method based on the hash value, instead of it's position:
```python
                rec_h, rec_pos = self.read_pair(
                    self.data[pos:pos+self.pair_size]
                )

                if not rec_h:
                    break
                elif rec_h == h:
                    ...
```

Thanks for working on this package!


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml
index 85cd2fd..1d8ee28 100644
--- a/.github/workflows/python-app.yml
+++ b/.github/workflows/python-app.yml
@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9, 3.10.0-rc.1, pypy3]
+        python-version: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy3"]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/cdblib/cdblib.py b/cdblib/cdblib.py
index 87bbde0..8c87bb2 100644
--- a/cdblib/cdblib.py
+++ b/cdblib/cdblib.py
@@ -182,30 +182,58 @@ def __len__(self):
 
     def gets(self, key):
         '''Yield values for key in insertion order.'''
-        key, h = self.hash_key(key)
-        start, nslots = self.index[h & 0xff]
+        # Algorithm from the spec: https://cr.yp.to/cdb/cdb.txt
+        # "Compute the hash value of the key in the record."
+        key, hashed_key = self.hash_key(key)
+
+        # "The hash value modulo 256 is the number of a hash table."
+        slot_number, table_number = divmod(hashed_key, 256)
+        table_pos, table_len = self.index[table_number]
+
+        # If the length of that table is 0, then there's no matching record.
+        if not table_len:
+            return
+
+        # Otherwise, compute the position of the end of the table - we'll
+        # need it to be able to search the whole table.
+        table_end = table_pos + (self.pair_size * table_len)
+
+        # "The hash value divided by 256, modulo the length of that table, is a
+        # slot number."
+        slot_number %= table_len
+
+        # "Probe that slot, the next higher slot, and so on, until you find the
+        # record or run into an empty slot."
+        slot_pos = table_pos + (self.pair_size * slot_number)
+        while True:
+            hash_value, byte_pos = self.read_pair(
+                self.data[slot_pos:slot_pos + self.pair_size]
+            )
+            slot_pos += self.pair_size
+
+            # We ran into an empty slot: the search is finished.
+            if not byte_pos:
+                break
+
+            # Potential hit - we might have found a matching record.
+            if hash_value == hashed_key:
+                key_size, value_size = self.read_pair(
+                    self.data[byte_pos:byte_pos + self.pair_size]
+                )
+                byte_pos += self.pair_size
 
-        if nslots:
-            end = start + (nslots * self.pair_size)
-            slot_off = start + (((h >> 8) % nslots) * self.pair_size)
+                candidate_key = self.data[byte_pos:byte_pos+key_size]
+                byte_pos += key_size
 
-            for pos in chain(range(slot_off, end, self.pair_size),
-                             range(start, slot_off, self.pair_size)):
-                rec_h, rec_pos = self.read_pair(
-                    self.data[pos:pos+self.pair_size]
-                )
+                candidate_value = self.data[byte_pos:byte_pos+value_size]
+                byte_pos += value_size
+                if candidate_key == key:
+                    yield candidate_value
 
-                if not rec_h:
-                    break
-                elif rec_h == h:
-                    klen, dlen = self.read_pair(
-                        self.data[rec_pos:rec_pos+self.pair_size]
-                    )
-                    rec_pos += self.pair_size
-
-                    if self.data[rec_pos:rec_pos+klen] == key:
-                        rec_pos += klen
-                        yield self.data[rec_pos:rec_pos+dlen]
+            # If we've not run into an empty slot yet, we're not finished.
+            # To go to the "next higher slot," we jump to the table's start.
+            if slot_pos == table_end:
+                slot_pos = table_pos
 
     def get(self, key, default=None):
         '''Get the first value for key, returning default if missing.'''


Test patch:
diff --git a/tests/test_cdblib.py b/tests/test_cdblib.py
index 9da054b..b497f24 100755
--- a/tests/test_cdblib.py
+++ b/tests/test_cdblib.py
@@ -480,6 +480,17 @@ def test_putstrings_fail(self):
         ]:
             with self.assertRaises(exc_type):
                 self.writer.putstrings(key, value)
+    
+    def test_zero_hash(self):
+        # Adapted from https://github.com/bbayles/python-pure-cdb/issues/39
+        key1 = b'xyz'
+        key2 = bytes([*[13, 168, 240, 240, 64, 64, 128, 128, 128, 0, 128, 128, 0, 0, 0, 128, 128], *([0] * 692791), 128])  # noqa
+        value = b'abc'
+        self.writer.put(key1, value)
+        self.writer.put(key2, value)
+        reader = self.get_reader()
+        self.assertEqual(reader.get(key1), value)
+        self.assertEqual(reader.get(key2), value)
 
 
 class WriterNativeInterfaceDjbHashTestCase(WriterNativeInterfaceTestBase,