Skip to content

Commit 562cf5a

Browse files
committed
assert: fix premature deep strict comparison
Refactors _deepEqual and fixes a few code paths that lead to behaviors contradicting what the doc says. Before this commit certain types of objects (Buffers, Dates, etc.) are not checked properly, and can get away with different prototypes AND different enumerable owned properties because _deepEqual would jump to premature conclusion for them. Since we no longer follow CommonJS unit testing spec, the checks for primitives and object prototypes are moved forward for faster failure. Improve regexp and float* array checks: * Don't compare lastIndex of regexps, because they are not enumerable, so according to the docs they should not be compared * Compare flags of regexps instead of separate properties * Use built-in tags to test for float* arrays instead of using instanceof Use full link to the archived GitHub repository. Use util.objectToString for future improvements to that function that makes sure the call won't be tampered with. PR-URL: #11128 Refs: #10282 (comment) Refs: #10258 (comment) Reviewed-By: James M Snell <[email protected]> Reviewed-By: Anna Henningsen <[email protected]> Reviewed-By: Rich Trott <[email protected]> Reviewed-By: Michaël Zasso <[email protected]>
1 parent 813b312 commit 562cf5a

5 files changed

+261
-100
lines changed

lib/assert.js

+144-85
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@
2323
// UTILITY
2424
const compare = process.binding('buffer').compare;
2525
const util = require('util');
26+
const objectToString = require('internal/util').objectToString;
2627
const Buffer = require('buffer').Buffer;
27-
const pToString = (obj) => Object.prototype.toString.call(obj);
2828

2929
// The assert module provides functions that throw
3030
// AssertionError's when particular conditions are not met. The
@@ -136,117 +136,177 @@ assert.deepStrictEqual = function deepStrictEqual(actual, expected, message) {
136136
}
137137
};
138138

139+
function areSimilarRegExps(a, b) {
140+
return a.source === b.source && a.flags === b.flags;
141+
}
142+
143+
function areSimilarTypedArrays(a, b) {
144+
return compare(Buffer.from(a.buffer,
145+
a.byteOffset,
146+
a.byteLength),
147+
Buffer.from(b.buffer,
148+
b.byteOffset,
149+
b.byteLength)) === 0;
150+
}
151+
152+
function isNullOrNonObj(object) {
153+
return object === null || typeof object !== 'object';
154+
}
155+
156+
function isFloatTypedArrayTag(tag) {
157+
return tag === '[object Float32Array]' || tag === '[object Float64Array]';
158+
}
159+
160+
function isArguments(tag) {
161+
return tag === '[object Arguments]';
162+
}
163+
139164
function _deepEqual(actual, expected, strict, memos) {
140165
// All identical values are equivalent, as determined by ===.
141166
if (actual === expected) {
142167
return true;
168+
}
143169

144-
// If both values are instances of buffers, equivalence is
145-
// determined by comparing the values and ensuring the result
146-
// === 0.
147-
} else if (actual instanceof Buffer && expected instanceof Buffer) {
148-
return compare(actual, expected) === 0;
149-
150-
// If the expected value is a Date object, the actual value is
151-
// equivalent if it is also a Date object that refers to the same time.
152-
} else if (util.isDate(actual) && util.isDate(expected)) {
153-
return actual.getTime() === expected.getTime();
154-
155-
// If the expected value is a RegExp object, the actual value is
156-
// equivalent if it is also a RegExp object with the same source and
157-
// properties (`global`, `multiline`, `lastIndex`, `ignoreCase`).
158-
} else if (util.isRegExp(actual) && util.isRegExp(expected)) {
159-
return actual.source === expected.source &&
160-
actual.global === expected.global &&
161-
actual.multiline === expected.multiline &&
162-
actual.lastIndex === expected.lastIndex &&
163-
actual.ignoreCase === expected.ignoreCase;
164-
165-
// If both values are primitives, equivalence is determined by
166-
// == or, if checking for strict equivalence, ===.
167-
} else if ((actual === null || typeof actual !== 'object') &&
168-
(expected === null || typeof expected !== 'object')) {
170+
// For primitives / functions
171+
// (determined by typeof value !== 'object'),
172+
// or null, equivalence is determined by === or ==.
173+
if (isNullOrNonObj(actual) && isNullOrNonObj(expected)) {
169174
return strict ? actual === expected : actual == expected;
175+
}
170176

171-
// If both values are instances of typed arrays, wrap their underlying
172-
// ArrayBuffers in a Buffer to increase performance.
173-
// This optimization requires the arrays to have the same type as checked by
174-
// Object.prototype.toString (pToString). Never perform binary
175-
// comparisons for Float*Arrays, though, since +0 === -0 is true despite the
176-
// two values' bit patterns not being identical.
177-
} else if (ArrayBuffer.isView(actual) && ArrayBuffer.isView(expected) &&
178-
pToString(actual) === pToString(expected) &&
179-
!(actual instanceof Float32Array ||
180-
actual instanceof Float64Array)) {
181-
return compare(Buffer.from(actual.buffer,
182-
actual.byteOffset,
183-
actual.byteLength),
184-
Buffer.from(expected.buffer,
185-
expected.byteOffset,
186-
expected.byteLength)) === 0;
187-
188-
// For all other Object pairs, including Array objects, equivalence is
189-
// determined by having the same number of owned properties (as verified
190-
// with Object.prototype.hasOwnProperty.call), the same set of keys
191-
// (although not necessarily the same order), equivalent values for every
192-
// corresponding key, and an identical 'prototype' property. Note: this
193-
// accounts for both named and indexed properties on Arrays.
194-
} else {
195-
memos = memos || {actual: [], expected: []};
177+
// If they bypass the previous check, then at least
178+
// one of them must be an non-null object.
179+
// If the other one is null or undefined, they must not be equal.
180+
if (actual === null || actual === undefined ||
181+
expected === null || expected === undefined)
182+
return false;
196183

197-
const actualIndex = memos.actual.indexOf(actual);
198-
if (actualIndex !== -1) {
199-
if (actualIndex === memos.expected.indexOf(expected)) {
200-
return true;
201-
}
184+
// Notes: Type tags are historical [[Class]] properties that can be set by
185+
// FunctionTemplate::SetClassName() in C++ or Symbol.toStringTag in JS
186+
// and retrieved using Object.prototype.toString.call(obj) in JS
187+
// See https://tc39.github.io/ecma262/#sec-object.prototype.tostring
188+
// for a list of tags pre-defined in the spec.
189+
// There are some unspecified tags in the wild too (e.g. typed array tags).
190+
// Since tags can be altered, they only serve fast failures
191+
const actualTag = objectToString(actual);
192+
const expectedTag = objectToString(expected);
193+
194+
// Passing null or undefined to Object.getPrototypeOf() will throw
195+
// so this must done after previous checks.
196+
// For strict comparison, objects should have
197+
// a) The same prototypes.
198+
// b) The same built-in type tags
199+
if (strict) {
200+
if (Object.getPrototypeOf(actual) !== Object.getPrototypeOf(expected)) {
201+
return false;
202202
}
203+
}
203204

204-
memos.actual.push(actual);
205-
memos.expected.push(expected);
205+
// Do fast checks for builtin types.
206+
// If they don't match, they must not be equal.
207+
// If they match, return true for non-strict comparison.
208+
// For strict comparison we need to exam further.
206209

207-
return objEquiv(actual, expected, strict, memos);
210+
// If both values are Date objects,
211+
// check if the time underneath are equal first.
212+
if (util.isDate(actual) && util.isDate(expected)) {
213+
if (actual.getTime() !== expected.getTime()) {
214+
return false;
215+
} else if (!strict) {
216+
return true; // Skip further checks for non-strict comparison.
217+
}
208218
}
209-
}
210219

211-
function isArguments(object) {
212-
return Object.prototype.toString.call(object) === '[object Arguments]';
213-
}
220+
// If both values are RegExp, check if they have
221+
// the same source and flags first
222+
if (util.isRegExp(actual) && util.isRegExp(expected)) {
223+
if (!areSimilarRegExps(actual, expected)) {
224+
return false;
225+
} else if (!strict) {
226+
return true; // Skip further checks for non-strict comparison.
227+
}
228+
}
214229

215-
function objEquiv(a, b, strict, actualVisitedObjects) {
216-
if (a === null || a === undefined || b === null || b === undefined)
230+
// Ensure reflexivity of deepEqual with `arguments` objects.
231+
// See https://github.com/nodejs/node-v0.x-archive/pull/7178
232+
if (isArguments(actualTag) !== isArguments(expectedTag)) {
217233
return false;
234+
}
235+
236+
// Check typed arrays and buffers by comparing the content in their
237+
// underlying ArrayBuffer. This optimization requires that it's
238+
// reasonable to interpret their underlying memory in the same way,
239+
// which is checked by comparing their type tags.
240+
// (e.g. a Uint8Array and a Uint16Array with the same memory content
241+
// could still be different because they will be interpreted differently)
242+
// Never perform binary comparisons for Float*Arrays, though,
243+
// since e.g. +0 === -0 is true despite the two values' bit patterns
244+
// not being identical.
245+
if (ArrayBuffer.isView(actual) && ArrayBuffer.isView(expected) &&
246+
actualTag === expectedTag && !isFloatTypedArrayTag(actualTag)) {
247+
if (!areSimilarTypedArrays(actual, expected)) {
248+
return false;
249+
} else if (!strict) {
250+
return true; // Skip further checks for non-strict comparison.
251+
}
252+
253+
// Buffer.compare returns true, so actual.length === expected.length
254+
// if they both only contain numeric keys, we don't need to exam further
255+
if (Object.keys(actual).length === actual.length &&
256+
Object.keys(expected).length === expected.length) {
257+
return true;
258+
}
259+
}
260+
261+
// For all other Object pairs, including Array objects,
262+
// equivalence is determined by having:
263+
// a) The same number of owned enumerable properties
264+
// b) The same set of keys/indexes (although not necessarily the same order)
265+
// c) Equivalent values for every corresponding key/index
266+
// Note: this accounts for both named and indexed properties on Arrays.
267+
268+
// Use memos to handle cycles.
269+
memos = memos || { actual: [], expected: [] };
270+
const actualIndex = memos.actual.indexOf(actual);
271+
if (actualIndex !== -1) {
272+
if (actualIndex === memos.expected.indexOf(expected)) {
273+
return true;
274+
}
275+
}
276+
memos.actual.push(actual);
277+
memos.expected.push(expected);
218278

219-
// If one is a primitive, the other must be the same.
279+
return objEquiv(actual, expected, strict, memos);
280+
}
281+
282+
function objEquiv(a, b, strict, actualVisitedObjects) {
283+
// If one of them is a primitive, the other must be the same.
220284
if (util.isPrimitive(a) || util.isPrimitive(b))
221285
return a === b;
222-
if (strict && Object.getPrototypeOf(a) !== Object.getPrototypeOf(b))
223-
return false;
224-
const aIsArgs = isArguments(a);
225-
const bIsArgs = isArguments(b);
226-
if ((aIsArgs && !bIsArgs) || (!aIsArgs && bIsArgs))
227-
return false;
228-
const ka = Object.keys(a);
229-
const kb = Object.keys(b);
286+
287+
const aKeys = Object.keys(a);
288+
const bKeys = Object.keys(b);
230289
var key, i;
231290

232-
// The pair must have the same number of owned properties (keys
233-
// incorporates hasOwnProperty).
234-
if (ka.length !== kb.length)
291+
// The pair must have the same number of owned properties
292+
// (keys incorporates hasOwnProperty).
293+
if (aKeys.length !== bKeys.length)
235294
return false;
236295

237296
// The pair must have the same set of keys (although not
238297
// necessarily in the same order).
239-
ka.sort();
240-
kb.sort();
298+
aKeys.sort();
299+
bKeys.sort();
241300
// Cheap key test:
242-
for (i = ka.length - 1; i >= 0; i--) {
243-
if (ka[i] !== kb[i])
301+
for (i = aKeys.length - 1; i >= 0; i--) {
302+
if (aKeys[i] !== bKeys[i])
244303
return false;
245304
}
305+
246306
// The pair must have equivalent values for every corresponding key.
247307
// Possibly expensive deep test:
248-
for (i = ka.length - 1; i >= 0; i--) {
249-
key = ka[i];
308+
for (i = aKeys.length - 1; i >= 0; i--) {
309+
key = aKeys[i];
250310
if (!_deepEqual(a[key], b[key], strict, actualVisitedObjects))
251311
return false;
252312
}
@@ -269,7 +329,6 @@ function notDeepStrictEqual(actual, expected, message) {
269329
}
270330
}
271331

272-
273332
// The strict equality assertion tests strict equality, as determined by ===.
274333
// assert.strictEqual(actual, expected, message_opt);
275334

@@ -295,7 +354,7 @@ function expectedException(actual, expected) {
295354
return false;
296355
}
297356

298-
if (Object.prototype.toString.call(expected) === '[object RegExp]') {
357+
if (objectToString(expected) === '[object RegExp]') {
299358
return expected.test(actual);
300359
}
301360

test/parallel/test-assert-deep.js

+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
'use strict';
2+
require('../common');
3+
const assert = require('assert');
4+
const util = require('util');
5+
6+
// Template tag function turning an error message into a RegExp
7+
// for assert.throws()
8+
function re(literals, ...values) {
9+
let result = literals[0];
10+
for (const [i, value] of values.entries()) {
11+
const str = util.inspect(value);
12+
// Need to escape special characters.
13+
result += str.replace(/[\\^$.*+?()[\]{}|=!<>:-]/g, '\\$&');
14+
result += literals[i + 1];
15+
}
16+
return new RegExp(`^AssertionError: ${result}$`);
17+
}
18+
19+
// The following deepEqual tests might seem very weird.
20+
// They just describe what it is now.
21+
// That is why we discourage using deepEqual in our own tests.
22+
23+
// Turn off no-restricted-properties because we are testing deepEqual!
24+
/* eslint-disable no-restricted-properties */
25+
26+
const arr = new Uint8Array([120, 121, 122, 10]);
27+
const buf = Buffer.from(arr);
28+
// They have different [[Prototype]]
29+
assert.throws(() => assert.deepStrictEqual(arr, buf));
30+
assert.doesNotThrow(() => assert.deepEqual(arr, buf));
31+
32+
const buf2 = Buffer.from(arr);
33+
buf2.prop = 1;
34+
35+
assert.throws(() => assert.deepStrictEqual(buf2, buf));
36+
assert.doesNotThrow(() => assert.deepEqual(buf2, buf));
37+
38+
const arr2 = new Uint8Array([120, 121, 122, 10]);
39+
arr2.prop = 5;
40+
assert.throws(() => assert.deepStrictEqual(arr, arr2));
41+
assert.doesNotThrow(() => assert.deepEqual(arr, arr2));
42+
43+
const date = new Date('2016');
44+
45+
class MyDate extends Date {
46+
constructor(...args) {
47+
super(...args);
48+
this[0] = '1';
49+
}
50+
}
51+
52+
const date2 = new MyDate('2016');
53+
54+
// deepEqual returns true as long as the time are the same,
55+
// but deepStrictEqual checks own properties
56+
assert.doesNotThrow(() => assert.deepEqual(date, date2));
57+
assert.doesNotThrow(() => assert.deepEqual(date2, date));
58+
assert.throws(() => assert.deepStrictEqual(date, date2),
59+
re`${date} deepStrictEqual ${date2}`);
60+
assert.throws(() => assert.deepStrictEqual(date2, date),
61+
re`${date2} deepStrictEqual ${date}`);
62+
63+
class MyRegExp extends RegExp {
64+
constructor(...args) {
65+
super(...args);
66+
this[0] = '1';
67+
}
68+
}
69+
70+
const re1 = new RegExp('test');
71+
const re2 = new MyRegExp('test');
72+
73+
// deepEqual returns true as long as the regexp-specific properties
74+
// are the same, but deepStrictEqual checks all properties
75+
assert.doesNotThrow(() => assert.deepEqual(re1, re2));
76+
assert.throws(() => assert.deepStrictEqual(re1, re2),
77+
re`${re1} deepStrictEqual ${re2}`);
78+
79+
// For these weird cases, deepEqual should pass (at least for now),
80+
// but deepStrictEqual should throw.
81+
const similar = new Set([
82+
{0: '1'}, // Object
83+
{0: 1}, // Object
84+
new String('1'), // Object
85+
['1'], // Array
86+
[1], // Array
87+
date2, // Date with this[0] = '1'
88+
re2, // RegExp with this[0] = '1'
89+
new Int8Array([1]), // Int8Array
90+
new Uint8Array([1]), // Uint8Array
91+
new Int16Array([1]), // Int16Array
92+
new Uint16Array([1]), // Uint16Array
93+
new Int32Array([1]), // Int32Array
94+
new Uint32Array([1]), // Uint32Array
95+
Buffer.from([1]),
96+
// Arguments {'0': '1'} is not here
97+
// See https://github.com/nodejs/node-v0.x-archive/pull/7178
98+
]);
99+
100+
for (const a of similar) {
101+
for (const b of similar) {
102+
if (a !== b) {
103+
assert.doesNotThrow(() => assert.deepEqual(a, b));
104+
assert.throws(() => assert.deepStrictEqual(a, b),
105+
re`${a} deepStrictEqual ${b}`);
106+
}
107+
}
108+
}
109+
110+
/* eslint-enable */

0 commit comments

Comments
 (0)