Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gh-119004: fix a crash in equality testing between OrderedDict #121329

Merged
merged 19 commits into from
Sep 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Doc/library/collections.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1169,8 +1169,11 @@ Some differences from :class:`dict` still remain:
In addition to the usual mapping methods, ordered dictionaries also support
reverse iteration using :func:`reversed`.

.. _collections_OrderedDict__eq__:

Equality tests between :class:`OrderedDict` objects are order-sensitive
and are implemented as ``list(od1.items())==list(od2.items())``.
and are roughly equivalent to ``list(od1.items())==list(od2.items())``.

Equality tests between :class:`OrderedDict` objects and other
:class:`~collections.abc.Mapping` objects are order-insensitive like regular
dictionaries. This allows :class:`OrderedDict` objects to be substituted
Expand All @@ -1186,7 +1189,7 @@ anywhere a regular dictionary is used.
method.

.. versionchanged:: 3.9
Added merge (``|``) and update (``|=``) operators, specified in :pep:`584`.
Added merge (``|``) and update (``|=``) operators, specified in :pep:`584`.


:class:`OrderedDict` Examples and Recipes
Expand Down
114 changes: 113 additions & 1 deletion Lib/test/test_ordered_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
import contextlib
import copy
import gc
import operator
import pickle
import re
from random import randrange, shuffle
import struct
import sys
Expand Down Expand Up @@ -740,11 +742,44 @@ def test_ordered_dict_items_result_gc(self):
# when it's mutated and returned from __next__:
self.assertTrue(gc.is_tracked(next(it)))


class _TriggerSideEffectOnEqual:
count = 0 # number of calls to __eq__
trigger = 1 # count value when to trigger side effect

def __eq__(self, other):
if self.__class__.count == self.__class__.trigger:
self.side_effect()
self.__class__.count += 1
return True

def __hash__(self):
# all instances represent the same key
return -1

def side_effect(self):
raise NotImplementedError

class PurePythonOrderedDictTests(OrderedDictTests, unittest.TestCase):

module = py_coll
OrderedDict = py_coll.OrderedDict

def test_issue119004_attribute_error(self):
class Key(_TriggerSideEffectOnEqual):
def side_effect(self):
del dict1[TODEL]

TODEL = Key()
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
# This causes an AttributeError due to the linked list being changed
msg = re.escape("'NoneType' object has no attribute 'key'")
self.assertRaisesRegex(AttributeError, msg, operator.eq, dict1, dict2)
self.assertEqual(Key.count, 2)
self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))


class CPythonBuiltinDictTests(unittest.TestCase):
"""Builtin dict preserves insertion order.
Expand All @@ -765,8 +800,85 @@ class CPythonBuiltinDictTests(unittest.TestCase):
del method


class CPythonOrderedDictSideEffects:

def check_runtime_error_issue119004(self, dict1, dict2):
msg = re.escape("OrderedDict mutated during iteration")
self.assertRaisesRegex(RuntimeError, msg, operator.eq, dict1, dict2)

def test_issue119004_change_size_by_clear(self):
class Key(_TriggerSideEffectOnEqual):
def side_effect(self):
dict1.clear()

dict1 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
self.check_runtime_error_issue119004(dict1, dict2)
self.assertEqual(Key.count, 2)
self.assertDictEqual(dict1, {})
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))

def test_issue119004_change_size_by_delete_key(self):
class Key(_TriggerSideEffectOnEqual):
def side_effect(self):
del dict1[TODEL]

TODEL = Key()
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
self.check_runtime_error_issue119004(dict1, dict2)
self.assertEqual(Key.count, 2)
self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))

def test_issue119004_change_linked_list_by_clear(self):
class Key(_TriggerSideEffectOnEqual):
def side_effect(self):
dict1.clear()
dict1['a'] = dict1['b'] = 'c'

dict1 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
self.check_runtime_error_issue119004(dict1, dict2)
self.assertEqual(Key.count, 2)
self.assertDictEqual(dict1, dict.fromkeys(('a', 'b'), 'c'))
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))

def test_issue119004_change_linked_list_by_delete_key(self):
class Key(_TriggerSideEffectOnEqual):
def side_effect(self):
del dict1[TODEL]
dict1['a'] = 'c'

TODEL = Key()
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
self.check_runtime_error_issue119004(dict1, dict2)
self.assertEqual(Key.count, 2)
self.assertDictEqual(dict1, {0: None, 'a': 'c', 4.2: None})
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))

def test_issue119004_change_size_by_delete_key_in_dict_eq(self):
class Key(_TriggerSideEffectOnEqual):
trigger = 0
def side_effect(self):
del dict1[TODEL]

TODEL = Key()
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
self.assertEqual(Key.count, 0)
# the side effect is in dict.__eq__ and modifies the length
self.assertNotEqual(dict1, dict2)
self.assertEqual(Key.count, 2)
self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))


@unittest.skipUnless(c_coll, 'requires the C version of the collections module')
class CPythonOrderedDictTests(OrderedDictTests, unittest.TestCase):
class CPythonOrderedDictTests(OrderedDictTests,
CPythonOrderedDictSideEffects,
unittest.TestCase):

module = c_coll
OrderedDict = c_coll.OrderedDict
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix a crash in :ref:`OrderedDict.__eq__ <collections_OrderedDict__eq__>`
when operands are mutated during the check. Patch by Bénédikt Tran.
33 changes: 25 additions & 8 deletions Objects/odictobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ _odict_clear_nodes(PyODictObject *od)
_odictnode_DEALLOC(node);
node = next;
}
od->od_state++;
}

/* There isn't any memory management of nodes past this point. */
Expand All @@ -806,24 +807,40 @@ _odict_keys_equal(PyODictObject *a, PyODictObject *b)
{
_ODictNode *node_a, *node_b;

// keep operands' state to detect undesired mutations
const size_t state_a = a->od_state;
const size_t state_b = b->od_state;

node_a = _odict_FIRST(a);
node_b = _odict_FIRST(b);
while (1) {
if (node_a == NULL && node_b == NULL)
if (node_a == NULL && node_b == NULL) {
/* success: hit the end of each at the same time */
return 1;
else if (node_a == NULL || node_b == NULL)
}
else if (node_a == NULL || node_b == NULL) {
/* unequal length */
return 0;
}
else {
int res = PyObject_RichCompareBool(
(PyObject *)_odictnode_KEY(node_a),
(PyObject *)_odictnode_KEY(node_b),
Py_EQ);
if (res < 0)
PyObject *key_a = Py_NewRef(_odictnode_KEY(node_a));
PyObject *key_b = Py_NewRef(_odictnode_KEY(node_b));
int res = PyObject_RichCompareBool(key_a, key_b, Py_EQ);
Py_DECREF(key_a);
Py_DECREF(key_b);
if (res < 0) {
return res;
else if (res == 0)
}
else if (a->od_state != state_a || b->od_state != state_b) {
PyErr_SetString(PyExc_RuntimeError,
"OrderedDict mutated during iteration");
return -1;
}
else if (res == 0) {
// This check comes after the check on the state
// in order for the exception to be set correctly.
return 0;
}

/* otherwise it must match, so move on to the next one */
node_a = _odictnode_NEXT(node_a);
Expand Down
Loading