Skip to content

Commit 4babb45

Browse files
kjelkoKevin Elko
and
Kevin Elko
authored
change(rc): Update Remote Config condition evaluation hashing (#2760)
* move from farmhash to sha256 * lint * add more special char test cases * another special char case * further refine test cases * clean up tests * Trigger CIs * fix newline --------- Co-authored-by: Kevin Elko <[email protected]>
1 parent dcef2ae commit 4babb45

File tree

2 files changed

+83
-128
lines changed

2 files changed

+83
-128
lines changed

src/remote-config/condition-evaluator-internal.ts

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import {
2727
CustomSignalCondition,
2828
CustomSignalOperator,
2929
} from './remote-config-api';
30-
import * as farmhash from 'farmhash-modern';
30+
import { createHash } from 'crypto';
31+
3132

3233
/**
3334
* Encapsulates condition evaluation logic to simplify organization and
@@ -151,9 +152,8 @@ export class ConditionEvaluator {
151152
const seedPrefix = seed && seed.length > 0 ? `${seed}.` : '';
152153
const stringToHash = `${seedPrefix}${context.randomizationId}`;
153154

154-
const hash64 = ConditionEvaluator.hashSeededRandomizationId(stringToHash)
155-
156-
const instanceMicroPercentile = hash64 % BigInt(100 * 1_000_000);
155+
const hash = ConditionEvaluator.hashSeededRandomizationId(stringToHash)
156+
const instanceMicroPercentile = hash % BigInt(100 * 1_000_000);
157157

158158
switch (percentOperator) {
159159
case PercentConditionOperator.LESS_OR_EQUAL:
@@ -173,18 +173,8 @@ export class ConditionEvaluator {
173173
}
174174

175175
static hashSeededRandomizationId(seededRandomizationId: string): bigint {
176-
// For consistency with the Remote Config fetch endpoint's percent condition behavior
177-
// we use Farmhash's fingerprint64 algorithm and interpret the resulting unsigned value
178-
// as a signed value.
179-
let hash64 = BigInt.asIntN(64, farmhash.fingerprint64(seededRandomizationId));
180-
181-
// Manually negate the hash if its value is less than 0, since Math.abs doesn't
182-
// support BigInt.
183-
if (hash64 < 0) {
184-
hash64 = -hash64;
185-
}
186-
187-
return hash64;
176+
const hex = createHash('sha256').update(seededRandomizationId).digest('hex');
177+
return BigInt(`0x${hex}`);
188178
}
189179

190180
private evaluateCustomSignalCondition(

test/unit/remote-config/condition-evaluator.spec.ts

Lines changed: 77 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from '../../../src/remote-config/remote-config-api';
2929
import { v4 as uuidv4 } from 'uuid';
3030
import { clone } from 'lodash';
31-
import * as farmhash from 'farmhash-modern';
31+
import * as crypto from 'crypto';
3232

3333
const expect = chai.expect;
3434

@@ -251,9 +251,8 @@ describe('ConditionEvaluator', () => {
251251

252252
it('should use zero for undefined microPercent', () => {
253253
// Stubs ID hasher to return a number larger than zero.
254-
const stub = sinon
255-
.stub(farmhash, 'fingerprint64')
256-
.returns(1n);
254+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
255+
stub.withArgs('hex').returns('1');
257256
stubs.push(stub);
258257

259258
const condition = {
@@ -284,9 +283,8 @@ describe('ConditionEvaluator', () => {
284283

285284
it('should use zeros for undefined microPercentRange', () => {
286285
// Stubs ID hasher to return a number in range.
287-
const stub = sinon
288-
.stub(farmhash, 'fingerprint64')
289-
.returns(1n);
286+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
287+
stub.withArgs('hex').returns('1');
290288
stubs.push(stub);
291289

292290
const condition = {
@@ -317,9 +315,8 @@ describe('ConditionEvaluator', () => {
317315

318316
it('should use zero for undefined microPercentUpperBound', () => {
319317
// Stubs ID hasher to return a number outside range.
320-
const stub = sinon
321-
.stub(farmhash, 'fingerprint64')
322-
.returns(1n);
318+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
319+
stub.withArgs('hex').returns('1');
323320
stubs.push(stub);
324321

325322
const condition = {
@@ -353,9 +350,8 @@ describe('ConditionEvaluator', () => {
353350

354351
it('should use zero for undefined microPercentLowerBound', () => {
355352
// Stubs ID hasher to return a number in range.
356-
const stub = sinon
357-
.stub(farmhash, 'fingerprint64')
358-
.returns(1n);
353+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
354+
stub.withArgs('hex').returns('1');
359355
stubs.push(stub);
360356

361357
const condition = {
@@ -388,9 +384,9 @@ describe('ConditionEvaluator', () => {
388384
});
389385

390386
it('should evaluate 9 as less or equal to 10', () => {
391-
const stub = sinon
392-
.stub(farmhash, 'fingerprint64')
393-
.returns(9n);
387+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
388+
stub.withArgs('hex').returns('9');
389+
stubs.push(stub);
394390

395391
stubs.push(stub);
396392
const condition = {
@@ -419,9 +415,9 @@ describe('ConditionEvaluator', () => {
419415
});
420416

421417
it('should evaluate 10 as less or equal to 10', () => {
422-
const stub = sinon
423-
.stub(farmhash, 'fingerprint64')
424-
.returns(10n);
418+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
419+
stub.withArgs('hex').returns((10).toString(16));
420+
stubs.push(stub);
425421

426422
stubs.push(stub);
427423
const condition = {
@@ -450,9 +446,9 @@ describe('ConditionEvaluator', () => {
450446
});
451447

452448
it('should evaluate 11 as not less or equal to 10', () => {
453-
const stub = sinon
454-
.stub(farmhash, 'fingerprint64')
455-
.returns(11n);
449+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
450+
stub.withArgs('hex').returns((11).toString(16));
451+
stubs.push(stub);
456452

457453
stubs.push(stub);
458454
const condition = {
@@ -481,9 +477,9 @@ describe('ConditionEvaluator', () => {
481477
});
482478

483479
it('should negate -11 to 11 and evaluate as not less or equal to 10', () => {
484-
const stub = sinon
485-
.stub(farmhash, 'fingerprint64')
486-
.returns(-11n);
480+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
481+
stub.withArgs('hex').returns((11).toString(16));
482+
stubs.push(stub);
487483

488484
stubs.push(stub);
489485
const condition = {
@@ -540,9 +536,9 @@ describe('ConditionEvaluator', () => {
540536
});
541537

542538
it('should evaluate 11M as greater than 10M', () => {
543-
const stub = sinon
544-
.stub(farmhash, 'fingerprint64')
545-
.returns(11n);
539+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
540+
stub.withArgs('hex').returns((11).toString(16));
541+
stubs.push(stub);
546542

547543
stubs.push(stub);
548544
const condition = {
@@ -571,9 +567,8 @@ describe('ConditionEvaluator', () => {
571567
});
572568

573569
it('should evaluate 9 as not greater than 10', () => {
574-
const stub = sinon
575-
.stub(farmhash, 'fingerprint64')
576-
.returns(9n);
570+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
571+
stub.withArgs('hex').returns('9');
577572
stubs.push(stub);
578573

579574
const condition = {
@@ -661,9 +656,8 @@ describe('ConditionEvaluator', () => {
661656
});
662657

663658
it('should evaluate 10 as between 9 and 11', () => {
664-
const stub = sinon
665-
.stub(farmhash, 'fingerprint64')
666-
.returns(10n);
659+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
660+
stub.withArgs('hex').returns((10).toString(16));
667661
stubs.push(stub);
668662

669663
const condition = {
@@ -726,9 +720,8 @@ describe('ConditionEvaluator', () => {
726720
});
727721

728722
it('should evaluate 12 as not between 9 and 11', () => {
729-
const stub = sinon
730-
.stub(farmhash, 'fingerprint64')
731-
.returns(12n);
723+
const stub = sinon.stub(crypto.Hash.prototype, 'digest');
724+
stub.withArgs('hex').returns((12).toString(16));
732725
stubs.push(stub);
733726

734727
const condition = {
@@ -759,6 +752,51 @@ describe('ConditionEvaluator', () => {
759752
expect(actual).to.be.false;
760753
});
761754

755+
describe('known percent condition values', () => {
756+
// This test is useful for ensuring consistency across all places we
757+
// evaluate percent conditions. It creates a set of 10 conditions targeting 50%
758+
// with randomizationIds 0-9 and a constant `seed` value.
759+
const conditionEvaluator = new ConditionEvaluator();
760+
761+
const percentCondition = {
762+
percentOperator: PercentConditionOperator.BETWEEN,
763+
microPercentRange: {
764+
microPercentLowerBound: 0,
765+
microPercentUpperBound: 50_000_000 // 50%
766+
},
767+
};
768+
769+
const testCases = [
770+
{ seed: '1', randomizationId: 'one', result: false },
771+
{ seed: '2', randomizationId: 'two', result: false },
772+
{ seed: '3', randomizationId: 'three', result: true },
773+
{ seed: '4', randomizationId: 'four', result: false },
774+
{ seed: '5', randomizationId: 'five', result: true },
775+
{ seed: '', randomizationId: '😊', result: true },
776+
{ seed: '', randomizationId: '😀', result: false },
777+
{ seed: 'hêl£o', randomizationId: 'wørlÐ', result: false },
778+
{ seed: 'řemøťe', randomizationId: 'çōnfįġ', result: true },
779+
{ seed: 'long', randomizationId: '.'.repeat(100), result: true },
780+
{ seed: 'very-long', randomizationId: '.'.repeat(1000), result: false },
781+
];
782+
783+
testCases.map(({ randomizationId, seed, result }) => {
784+
785+
const idSummary = randomizationId.length > 25
786+
? `a ${randomizationId.length} character randomizationID`
787+
: `"${randomizationId}"`;
788+
789+
it(`should evaluate ${idSummary} with seed "${seed}" to ${result}`, () => {
790+
const context = { randomizationId };
791+
const evalResult = conditionEvaluator.evaluateConditions([{
792+
name: 'is_enabled',
793+
condition: { percent: { ...percentCondition, seed } }
794+
}], context);
795+
expect(evalResult.get('is_enabled')).to.equal(result);
796+
});
797+
});
798+
});
799+
762800
// The following tests are probabilistic. They use tolerances based on
763801
// standard deviations to balance accuracy and flakiness. Random IDs will
764802
// hash to the target range + 3 standard deviations 99.7% of the time,
@@ -958,15 +996,15 @@ describe('ConditionEvaluator', () => {
958996
describe('STRING_CONTAINS', () => {
959997
const testCases: CustomSignalTestCase[] = [
960998
{ targets: ['foo', 'biz'], actual: 'foobar', outcome: true },
961-
{ targets: ['foo', 'biz'],actual: 'bar',outcome: false },
999+
{ targets: ['foo', 'biz'], actual: 'bar', outcome: false },
9621000
];
9631001

9641002
testCases.forEach(runCustomSignalTestCase(CustomSignalOperator.STRING_CONTAINS));
9651003
});
9661004

9671005
describe('STRING_DOES_NOT_CONTAIN', () => {
9681006
const testCases: CustomSignalTestCase[] = [
969-
{ targets: ['foo', 'biz'],actual: 'bar',outcome: true },
1007+
{ targets: ['foo', 'biz'], actual: 'bar', outcome: true },
9701008
{ targets: ['foo', 'biz'], actual: 'foobar', outcome: false },
9711009
];
9721010

@@ -1135,77 +1173,4 @@ describe('ConditionEvaluator', () => {
11351173
});
11361174
});
11371175
});
1138-
1139-
describe('hashSeededRandomizationId', () => {
1140-
// The Farmhash algorithm produces a 64 bit unsigned integer,
1141-
// which we convert to a signed integer for legacy compatibility.
1142-
// This has caused confusion in the past, so we explicitly
1143-
// test here.
1144-
it('should leave numbers <= 2^63-1 (max signed long) as is', function () {
1145-
if (nodeVersion.startsWith('14')) {
1146-
this.skip();
1147-
}
1148-
1149-
const stub = sinon
1150-
.stub(farmhash, 'fingerprint64')
1151-
// 2^63-1 = 9223372036854775807.
1152-
.returns(BigInt('9223372036854775807'));
1153-
stubs.push(stub);
1154-
1155-
const actual = ConditionEvaluator.hashSeededRandomizationId('anything');
1156-
1157-
expect(actual).to.equal(BigInt('9223372036854775807'))
1158-
});
1159-
1160-
it('should convert 2^63 to negative (min signed long) and then find the absolute value', function () {
1161-
if (nodeVersion.startsWith('14')) {
1162-
this.skip();
1163-
}
1164-
1165-
const stub = sinon
1166-
.stub(farmhash, 'fingerprint64')
1167-
// 2^63 = 9223372036854775808.
1168-
.returns(BigInt('9223372036854775808'));
1169-
stubs.push(stub);
1170-
1171-
const actual = ConditionEvaluator.hashSeededRandomizationId('anything');
1172-
1173-
// 2^63 is the negation of 2^63-1
1174-
expect(actual).to.equal(BigInt('9223372036854775808'))
1175-
});
1176-
1177-
it('should convert 2^63+1 to negative and then find the absolute value', function () {
1178-
if (nodeVersion.startsWith('14')) {
1179-
this.skip();
1180-
}
1181-
1182-
const stub = sinon
1183-
.stub(farmhash, 'fingerprint64')
1184-
// 2^63+1 9223372036854775809.
1185-
.returns(BigInt('9223372036854775809'));
1186-
stubs.push(stub);
1187-
1188-
const actual = ConditionEvaluator.hashSeededRandomizationId('anything');
1189-
1190-
// 2^63+1 is larger than 2^63, so the absolute value is smaller
1191-
expect(actual).to.equal(BigInt('9223372036854775807'))
1192-
});
1193-
1194-
it('should handle the value that initially caused confusion', function () {
1195-
if (nodeVersion.startsWith('14')) {
1196-
this.skip();
1197-
}
1198-
1199-
const stub = sinon
1200-
.stub(farmhash, 'fingerprint64')
1201-
// We were initially confused about the nature of this value ...
1202-
.returns(BigInt('16081085603393958147'));
1203-
stubs.push(stub);
1204-
1205-
const actual = ConditionEvaluator.hashSeededRandomizationId('anything');
1206-
1207-
// ... Now we know it's the unsigned equivalent of this absolute value.
1208-
expect(actual).to.equal(BigInt('2365658470315593469'))
1209-
});
1210-
});
12111176
});

0 commit comments

Comments
 (0)