Skip to content

Commit 444682c

Browse files
authored
Fuzzing: Allow running wasm exports in different orders in fuzz_shell.js (#7204)
Normally fuzz_shell.js runs the exports in the natural order, when callExports is called. There is benefit to running them in different orders, just to get more variety at runtime. To allow that, it now receives a random seed that, if provided, it uses to determine the order at runtime. This is primarily useful for ClusterFuzz, which adds more executions of callExports. We now add such a seed to those.
1 parent 9dfd3d9 commit 444682c

File tree

4 files changed

+137
-15
lines changed

4 files changed

+137
-15
lines changed

scripts/clusterfuzz/run.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,24 @@ def get_js_file_contents(i, output_dir):
228228
extra_js_operations = [
229229
# Compile and link the wasm again. Each link adds more to the total
230230
# exports that we can call.
231-
'build(binary);\n',
232-
# Run all the exports we've accumulated.
233-
'callExports();\n',
231+
'build(binary)',
232+
# Run all the exports we've accumulated. This is a placeholder, as we
233+
# must pick a random seed for each (the placeholder would cause a JS
234+
# error at runtime if we had a bug and did not replace it properly).
235+
'CALL_EXPORTS',
234236
]
235237
if has_second:
236238
extra_js_operations += [
237-
'build(secondBinary);\n',
239+
'build(secondBinary)',
238240
]
239241

240242
for i in range(num):
241-
js += system_random.choice(extra_js_operations)
243+
choice = system_random.choice(extra_js_operations)
244+
if choice == 'CALL_EXPORTS':
245+
# The random seed can be any unsigned 32-bit number.
246+
seed = system_random.randint(0, 0xffffffff)
247+
choice = f'callExports({seed})'
248+
js += choice + ';\n'
242249

243250
print(f'Created {bytes} wasm bytes')
244251

scripts/fuzz_shell.js

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -371,20 +371,49 @@ function build(binary) {
371371
}
372372
}
373373

374-
// Run the code by calling exports.
375-
/* async */ function callExports() {
374+
// Simple deterministic hashing, on an unsigned 32-bit seed. See e.g.
375+
// https://www.boost.org/doc/libs/1_55_0/doc/html/hash/reference.html#boost.hash_combine
376+
function hashCombine(seed, value) {
377+
seed ^= value + 0x9e3779b9 + (seed << 6) + (seed >>> 2);
378+
return seed >>> 0;
379+
}
380+
381+
// Run the code by calling exports. The optional |ordering| parameter indicates
382+
// howe we should order the calls to the exports: if it is not provided, we call
383+
// them in the natural order, which allows our output to be compared to other
384+
// executions of the wasm (e.g. from wasm-opt --fuzz-exec). If |ordering| is
385+
// provided, it is a random seed we use to make deterministic choices on
386+
// the order of calls.
387+
/* async */ function callExports(ordering) {
376388
// Call the exports we were told, or if we were not given an explicit list,
377389
// call them all.
378390
var relevantExports = exportsToCall || exportList;
379391

392+
if (ordering !== undefined) {
393+
// Copy the list, and sort it in the simple Fisher-Yates manner.
394+
// https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
395+
relevantExports = relevantExports.slice(0);
396+
for (var i = 0; i < relevantExports.length - 1; i++) {
397+
// Pick the index of the item to place at index |i|.
398+
ordering = hashCombine(ordering, i);
399+
// The number of items to pick from begins at the full length, then
400+
// decreases with i.
401+
var j = i + (ordering % (relevantExports.length - i));
402+
// Swap the item over here.
403+
var t = relevantExports[j];
404+
relevantExports[j] = relevantExports[i];
405+
relevantExports[i] = t;
406+
}
407+
}
408+
380409
for (var e of relevantExports) {
381410
var name, value;
382411
if (typeof e === 'string') {
383412
// We are given a string name to call. Look it up in the global namespace.
384413
name = e;
385414
value = exports[e];
386415
} else {
387-
// We are given an object form exportList, which bas both a name and a
416+
// We are given an object form exportList, which has both a name and a
388417
// value.
389418
name = e.name;
390419
value = e.value;
@@ -396,6 +425,8 @@ function build(binary) {
396425

397426
try {
398427
console.log('[fuzz-exec] calling ' + name);
428+
// TODO: Based on |ordering|, do not always await, leaving a promise
429+
// for later, so we interleave stacks.
399430
var result = /* await */ callFunc(value);
400431
if (typeof result !== 'undefined') {
401432
console.log('[fuzz-exec] note result: ' + name + ' => ' + printed(result));
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
;; Test that appending a run operation with a seed can lead to a different
2+
;; order of export calls.
3+
4+
(module
5+
(import "fuzzing-support" "log-i32" (func $log (param i32)))
6+
7+
(func $a (export "a") (result i32)
8+
(i32.const 10)
9+
)
10+
11+
(func $b (export "b") (result i32)
12+
(i32.const 20)
13+
)
14+
15+
(func $c (export "c") (result i32)
16+
(i32.const 30)
17+
)
18+
)
19+
20+
;; Run normally: we should see a,b,c called in order.
21+
;;
22+
;; RUN: wasm-opt %s -o %t.wasm -q
23+
;; RUN: node %S/../../../scripts/fuzz_shell.js %t.wasm | filecheck %s
24+
;;
25+
;; CHECK: [fuzz-exec] calling a
26+
;; CHECK: [fuzz-exec] note result: a => 10
27+
;; CHECK: [fuzz-exec] calling b
28+
;; CHECK: [fuzz-exec] note result: b => 20
29+
;; CHECK: [fuzz-exec] calling c
30+
;; CHECK: [fuzz-exec] note result: c => 30
31+
32+
;; Append another run with a seed that leads to a different order
33+
;;
34+
;; RUN: cp %S/../../../scripts/fuzz_shell.js %t.js
35+
;; RUN: echo "callExports(1337);" >> %t.js
36+
;; RUN: node %t.js %t.wasm | filecheck %s --check-prefix=APPENDED
37+
;;
38+
;; The original order: a,b,c
39+
;; APPENDED: [fuzz-exec] calling a
40+
;; APPENDED: [fuzz-exec] note result: a => 10
41+
;; APPENDED: [fuzz-exec] calling b
42+
;; APPENDED: [fuzz-exec] note result: b => 20
43+
;; APPENDED: [fuzz-exec] calling c
44+
;; APPENDED: [fuzz-exec] note result: c => 30
45+
46+
;; A new order: b,c,a
47+
;; APPENDED: [fuzz-exec] calling b
48+
;; APPENDED: [fuzz-exec] note result: b => 20
49+
;; APPENDED: [fuzz-exec] calling c
50+
;; APPENDED: [fuzz-exec] note result: c => 30
51+
;; APPENDED: [fuzz-exec] calling a
52+
;; APPENDED: [fuzz-exec] note result: a => 10
53+

test/unit/test_cluster_fuzz.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,19 @@ def test_file_contents(self):
294294
# one wasm in each testcase: each wasm has a chance.
295295
initial_content_regex = re.compile(r'[/][*] using initial content ([^ ]+) [*][/]')
296296

297+
# Some calls to callExports come with a random seed, so we have either
298+
#
299+
# callExports();
300+
# callExports(123456);
301+
#
302+
call_exports_regex = re.compile(r'callExports[(](\d*)[)]')
303+
297304
for i in range(1, N + 1):
298305
fuzz_file = os.path.join(temp_dir.name, f'fuzz-binaryen-{i}.js')
299306
with open(fuzz_file) as f:
300307
js = f.read()
301308
seen_builds.append(js.count('build(binary);'))
302-
seen_calls.append(js.count('callExports();'))
309+
seen_calls.append(re.findall(call_exports_regex, js))
303310
seen_second_builds.append(js.count('build(secondBinary);'))
304311

305312
# If JSPI is enabled, the async and await keywords should be
@@ -331,12 +338,36 @@ def test_file_contents(self):
331338

332339
print()
333340

334-
print('JS calls are distributed as ~ mean 4, stddev 5, median 2')
335-
print(f'mean JS calls: {statistics.mean(seen_calls)}')
336-
print(f'stdev JS calls: {statistics.stdev(seen_calls)}')
337-
print(f'median JS calls: {statistics.median(seen_calls)}')
338-
self.assertGreaterEqual(max(seen_calls), 2)
339-
self.assertGreater(statistics.stdev(seen_calls), 0)
341+
# Generate the counts of seen calls, for convenience. We convert
342+
# [['11', '22'], [], ['99']]
343+
# into
344+
# [2, 0, 1]
345+
num_seen_calls = [len(x) for x in seen_calls]
346+
print('Num JS calls are distributed as ~ mean 4, stddev 5, median 2')
347+
print(f'mean JS calls: {statistics.mean(num_seen_calls)}')
348+
print(f'stdev JS calls: {statistics.stdev(num_seen_calls)}')
349+
print(f'median JS calls: {statistics.median(num_seen_calls)}')
350+
self.assertGreaterEqual(max(num_seen_calls), 2)
351+
self.assertGreater(statistics.stdev(num_seen_calls), 0)
352+
353+
# The initial callExports have no seed (that makes the first, default,
354+
# callExports behave deterministically, so we can compare to
355+
# wasm-opt --fuzz-exec etc.), and all subsequent ones must have a seed.
356+
seeds = []
357+
for calls in seen_calls:
358+
if calls:
359+
self.assertEqual(calls[0], '')
360+
for other in calls[1:]:
361+
self.assertNotEqual(other, '')
362+
seeds.append(int(other))
363+
364+
# The seeds are random numbers in 0..2^32-1, so overlap between them
365+
# should be incredibly unlikely. Allow a few % of such overlap just to
366+
# avoid extremely rare errors.
367+
num_seeds = len(seeds)
368+
num_unique_seeds = len(set(seeds))
369+
print(f'unique JS call seeds: {num_unique_seeds} (should be almost {num_seeds})')
370+
self.assertGreaterEqual(num_unique_seeds / num_seeds, 0.95)
340371

341372
print()
342373

0 commit comments

Comments
 (0)