Skip to content

Commit 1d70dd2

Browse files
[3.12] gh-119004: fix a crash in equality testing between OrderedDict (GH-121329) (#124508)
gh-119004: fix a crash in equality testing between `OrderedDict` (GH-121329) (cherry picked from commit 38a887d) Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
1 parent 54c6cd7 commit 1d70dd2

File tree

4 files changed

+145
-11
lines changed

4 files changed

+145
-11
lines changed

Doc/library/collections.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1163,8 +1163,11 @@ Some differences from :class:`dict` still remain:
11631163
In addition to the usual mapping methods, ordered dictionaries also support
11641164
reverse iteration using :func:`reversed`.
11651165

1166+
.. _collections_OrderedDict__eq__:
1167+
11661168
Equality tests between :class:`OrderedDict` objects are order-sensitive
1167-
and are implemented as ``list(od1.items())==list(od2.items())``.
1169+
and are roughly equivalent to ``list(od1.items())==list(od2.items())``.
1170+
11681171
Equality tests between :class:`OrderedDict` objects and other
11691172
:class:`~collections.abc.Mapping` objects are order-insensitive like regular
11701173
dictionaries. This allows :class:`OrderedDict` objects to be substituted
@@ -1180,7 +1183,7 @@ anywhere a regular dictionary is used.
11801183
method.
11811184

11821185
.. versionchanged:: 3.9
1183-
Added merge (``|``) and update (``|=``) operators, specified in :pep:`584`.
1186+
Added merge (``|``) and update (``|=``) operators, specified in :pep:`584`.
11841187

11851188

11861189
:class:`OrderedDict` Examples and Recipes

Lib/test/test_ordered_dict.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import contextlib
33
import copy
44
import gc
5+
import operator
56
import pickle
7+
import re
68
from random import randrange, shuffle
79
import struct
810
import sys
@@ -739,11 +741,44 @@ def test_ordered_dict_items_result_gc(self):
739741
# when it's mutated and returned from __next__:
740742
self.assertTrue(gc.is_tracked(next(it)))
741743

744+
745+
class _TriggerSideEffectOnEqual:
746+
count = 0 # number of calls to __eq__
747+
trigger = 1 # count value when to trigger side effect
748+
749+
def __eq__(self, other):
750+
if self.__class__.count == self.__class__.trigger:
751+
self.side_effect()
752+
self.__class__.count += 1
753+
return True
754+
755+
def __hash__(self):
756+
# all instances represent the same key
757+
return -1
758+
759+
def side_effect(self):
760+
raise NotImplementedError
761+
742762
class PurePythonOrderedDictTests(OrderedDictTests, unittest.TestCase):
743763

744764
module = py_coll
745765
OrderedDict = py_coll.OrderedDict
746766

767+
def test_issue119004_attribute_error(self):
768+
class Key(_TriggerSideEffectOnEqual):
769+
def side_effect(self):
770+
del dict1[TODEL]
771+
772+
TODEL = Key()
773+
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
774+
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
775+
# This causes an AttributeError due to the linked list being changed
776+
msg = re.escape("'NoneType' object has no attribute 'key'")
777+
self.assertRaisesRegex(AttributeError, msg, operator.eq, dict1, dict2)
778+
self.assertEqual(Key.count, 2)
779+
self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
780+
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
781+
747782

748783
class CPythonBuiltinDictTests(unittest.TestCase):
749784
"""Builtin dict preserves insertion order.
@@ -764,8 +799,85 @@ class CPythonBuiltinDictTests(unittest.TestCase):
764799
del method
765800

766801

802+
class CPythonOrderedDictSideEffects:
803+
804+
def check_runtime_error_issue119004(self, dict1, dict2):
805+
msg = re.escape("OrderedDict mutated during iteration")
806+
self.assertRaisesRegex(RuntimeError, msg, operator.eq, dict1, dict2)
807+
808+
def test_issue119004_change_size_by_clear(self):
809+
class Key(_TriggerSideEffectOnEqual):
810+
def side_effect(self):
811+
dict1.clear()
812+
813+
dict1 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
814+
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
815+
self.check_runtime_error_issue119004(dict1, dict2)
816+
self.assertEqual(Key.count, 2)
817+
self.assertDictEqual(dict1, {})
818+
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
819+
820+
def test_issue119004_change_size_by_delete_key(self):
821+
class Key(_TriggerSideEffectOnEqual):
822+
def side_effect(self):
823+
del dict1[TODEL]
824+
825+
TODEL = Key()
826+
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
827+
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
828+
self.check_runtime_error_issue119004(dict1, dict2)
829+
self.assertEqual(Key.count, 2)
830+
self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
831+
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
832+
833+
def test_issue119004_change_linked_list_by_clear(self):
834+
class Key(_TriggerSideEffectOnEqual):
835+
def side_effect(self):
836+
dict1.clear()
837+
dict1['a'] = dict1['b'] = 'c'
838+
839+
dict1 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
840+
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
841+
self.check_runtime_error_issue119004(dict1, dict2)
842+
self.assertEqual(Key.count, 2)
843+
self.assertDictEqual(dict1, dict.fromkeys(('a', 'b'), 'c'))
844+
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
845+
846+
def test_issue119004_change_linked_list_by_delete_key(self):
847+
class Key(_TriggerSideEffectOnEqual):
848+
def side_effect(self):
849+
del dict1[TODEL]
850+
dict1['a'] = 'c'
851+
852+
TODEL = Key()
853+
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
854+
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
855+
self.check_runtime_error_issue119004(dict1, dict2)
856+
self.assertEqual(Key.count, 2)
857+
self.assertDictEqual(dict1, {0: None, 'a': 'c', 4.2: None})
858+
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
859+
860+
def test_issue119004_change_size_by_delete_key_in_dict_eq(self):
861+
class Key(_TriggerSideEffectOnEqual):
862+
trigger = 0
863+
def side_effect(self):
864+
del dict1[TODEL]
865+
866+
TODEL = Key()
867+
dict1 = self.OrderedDict(dict.fromkeys((0, TODEL, 4.2)))
868+
dict2 = self.OrderedDict(dict.fromkeys((0, Key(), 4.2)))
869+
self.assertEqual(Key.count, 0)
870+
# the side effect is in dict.__eq__ and modifies the length
871+
self.assertNotEqual(dict1, dict2)
872+
self.assertEqual(Key.count, 2)
873+
self.assertDictEqual(dict1, dict.fromkeys((0, 4.2)))
874+
self.assertDictEqual(dict2, dict.fromkeys((0, Key(), 4.2)))
875+
876+
767877
@unittest.skipUnless(c_coll, 'requires the C version of the collections module')
768-
class CPythonOrderedDictTests(OrderedDictTests, unittest.TestCase):
878+
class CPythonOrderedDictTests(OrderedDictTests,
879+
CPythonOrderedDictSideEffects,
880+
unittest.TestCase):
769881

770882
module = c_coll
771883
OrderedDict = c_coll.OrderedDict
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a crash in :ref:`OrderedDict.__eq__ <collections_OrderedDict__eq__>`
2+
when operands are mutated during the check. Patch by Bénédikt Tran.

Objects/odictobject.c

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,7 @@ _odict_clear_nodes(PyODictObject *od)
789789
_odictnode_DEALLOC(node);
790790
node = next;
791791
}
792+
od->od_state++;
792793
}
793794

794795
/* There isn't any memory management of nodes past this point. */
@@ -799,24 +800,40 @@ _odict_keys_equal(PyODictObject *a, PyODictObject *b)
799800
{
800801
_ODictNode *node_a, *node_b;
801802

803+
// keep operands' state to detect undesired mutations
804+
const size_t state_a = a->od_state;
805+
const size_t state_b = b->od_state;
806+
802807
node_a = _odict_FIRST(a);
803808
node_b = _odict_FIRST(b);
804809
while (1) {
805-
if (node_a == NULL && node_b == NULL)
810+
if (node_a == NULL && node_b == NULL) {
806811
/* success: hit the end of each at the same time */
807812
return 1;
808-
else if (node_a == NULL || node_b == NULL)
813+
}
814+
else if (node_a == NULL || node_b == NULL) {
809815
/* unequal length */
810816
return 0;
817+
}
811818
else {
812-
int res = PyObject_RichCompareBool(
813-
(PyObject *)_odictnode_KEY(node_a),
814-
(PyObject *)_odictnode_KEY(node_b),
815-
Py_EQ);
816-
if (res < 0)
819+
PyObject *key_a = Py_NewRef(_odictnode_KEY(node_a));
820+
PyObject *key_b = Py_NewRef(_odictnode_KEY(node_b));
821+
int res = PyObject_RichCompareBool(key_a, key_b, Py_EQ);
822+
Py_DECREF(key_a);
823+
Py_DECREF(key_b);
824+
if (res < 0) {
817825
return res;
818-
else if (res == 0)
826+
}
827+
else if (a->od_state != state_a || b->od_state != state_b) {
828+
PyErr_SetString(PyExc_RuntimeError,
829+
"OrderedDict mutated during iteration");
830+
return -1;
831+
}
832+
else if (res == 0) {
833+
// This check comes after the check on the state
834+
// in order for the exception to be set correctly.
819835
return 0;
836+
}
820837

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

0 commit comments

Comments
 (0)