Coverage Report

Created: 2026-10-28 06:34

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/hobbes/fuzz/fuzz_parse_expr.C
Line
Count
Source
1
// Fuzz the lexer and LALR parser via hobbes::cc::readExpr.
2
//
3
// Evaluating Hobbes code is running trusted native code, but *reading* source
4
// text sits on the near side of that trust boundary: lexing and parsing must
5
// be safe on arbitrary bytes. Nothing here is evaluated.
6
//
7
// Reading is not entirely free of compilation, though, and the difference
8
// shows up over a campaign rather than on any one input. Several things are
9
// compiled where they are read: a regex literal becomes a matching function
10
// (makeRegexFn determinizes it and defines the result in the compiler under
11
// a fresh name), a match expression is compiled to a decision tree, and a
12
// `parse {}` block becomes an LALR parser. Each definition stays in the
13
// compiler, and the types it took are interned in the process-wide type
14
// memo (tctorMaps in lang/type.C), which holds a reference of its own to
15
// each of them; nothing removes the definition, and only
16
// compactMTypeMemory() lets go of the types. So a `cc` reused across
17
// inputs, with the memo left alone, grows by everything it has ever read:
18
// about 168KB per read of the twenty character regex in OSS-Fuzz testcase
19
// 4850385077207040, and, on a corpus the fuzzer had grown from the seeds
20
// (3.7k inputs, mostly near-misses), about 270KB per input in the compiler
21
// and 400KB per input in the memo -- measured by reading them all and then
22
// destroying the compiler and compacting the memo, which gave every byte
23
// back. Left alone, such a campaign runs the process out of memory. That is
24
// what the testcase reports: an out-of-memory that ClusterFuzz could not
25
// minimize, because no single input causes it.
26
//
27
// So two things are done periodically, one for each half. The type memo is
28
// compacted every few dozen inputs, as the decoder harnesses do; that gives
29
// back the memo's half and costs about as much as a parse. The compiler is
30
// replaced every thousand inputs; that gives back its half, and costs what
31
// it takes to build one. Neither alone is enough: each leaves the other half
32
// growing without bound. An earlier version replaced the compiler only after
33
// a couple of thousand inputs containing a quote character, on the reasoning
34
// that only a regex literal could have grown it; matches and grammars grow it
35
// just as well, and on the corpus above that policy let it reach a gigabyte.
36
// Counts of inputs are a coarse stand-in for how much has accumulated, but a
37
// portable and predictable one -- resident size is not, because freeing
38
// memory does not hand it back to the operating system, so a harness that
39
// watched RSS rebuilt the compiler on every input once it first went over.
40
//
41
// Building a compiler is the one expensive thing this harness does: it
42
// compiles the bootstrap modules, about half a second unsanitized and several
43
// seconds under ASan. That matters under AFL++, whose driver forks a fresh
44
// process for the run and times every input against a fixed budget (OSS-Fuzz
45
// runs it with -t 5000+): a compiler built on the first input is charged to
46
// that input, and the seed corpus dry run then fails the build with a timeout
47
// before fuzzing starts. So the first compiler is built in
48
// LLVMFuzzerInitialize, which every driver runs before the fork server and
49
// before any input is timed. Replacements still land inside a timed input;
50
// a thousand inputs between them keeps them rare.
51
52
#include <hobbes/hobbes.H>
53
54
#include <cstddef>
55
#include <cstdint>
56
#include <exception>
57
#include <memory>
58
#include <string>
59
60
// Hobbes reclaims evaluation memory by resetting an arena at the end of a
61
// transaction, not by releasing objects one at a time -- so a per-allocation
62
// leak audit reports transaction-scoped data as lost by design. For this
63
// target that means two families of expected reports: allocations made by
64
// grammar actions during a parse (yyparse and the paths into it), which the
65
// compiler's bootstrap parse strands once per cc, and a little that LLVM
66
// keeps for itself. The .options file ships detect_leaks=0 for exactly this
67
// reason. ClusterFuzz's libFuzzer and AFL engines apply that file when they
68
// replay a testcase; its honggfuzz engine does not, so on the honggfuzz job
69
// every replay ends in an at-exit leak report. A leak report is a crash, and
70
// for a Stack-overflow testcase progression accepts any crash as "still
71
// reproduces" (it compares crash state for the other types, but a stack
72
// overflow's state is too unstable to compare), so a fixed stack overflow
73
// found by honggfuzz stays open for as long as the bootstrap leaks are
74
// reported. OSS-Fuzz 549863810 and 556791547 sat that way.
75
//
76
// An earlier version of this file named the expected stacks in
77
// __lsan_default_suppressions, to keep the checker alive for everything
78
// else. That does not work where it is needed: ClusterFuzz runs every target
79
// with symbolize=0 and symbolizes offline, and with symbolization off LSan
80
// has no function names to match `leak:yyparse` against, so the suppressions
81
// matched locally and never on a bot. Turning the checker off is the only
82
// form of the policy that is binding whatever the engine or environment;
83
// nothing is given up, since the shipped .options already turns it off for
84
// the engines that honor it.
85
0
extern "C" int __lsan_is_turned_off() {
86
0
  return 1;
87
0
}
88
89
namespace {
90
91
const unsigned long inputsPerCompaction = 64;
92
const unsigned long inputsPerCompiler   = 1024;
93
94
// The first compiler is built in the slot's initializer rather than on first
95
// use. Constructing a cc also constructs the LLVM statics it depends on, and
96
// at exit everything static is destroyed in reverse order of construction: a
97
// slot that was registered empty and filled afterwards would be destroyed
98
// after those statics, and the cc inside it would tear down against an LLVM
99
// context that was already gone.
100
6.95k
std::unique_ptr<hobbes::cc>& compilerSlot() {
101
6.95k
  static std::unique_ptr<hobbes::cc> c(new hobbes::cc());
102
6.95k
  return c;
103
6.95k
}
104
105
6.95k
hobbes::cc& compiler() {
106
6.95k
  std::unique_ptr<hobbes::cc>& c = compilerSlot();
107
6.95k
  if (!c) {
108
6
    c = std::unique_ptr<hobbes::cc>(new hobbes::cc());
109
6
  }
110
6.95k
  return *c;
111
6.95k
}
112
113
6.95k
void reclaimPeriodically() {
114
6.95k
  static unsigned long read = 0;
115
6.95k
  ++read;
116
6.95k
  if (read % inputsPerCompiler == 0) {
117
6
    compilerSlot().reset();
118
6
  }
119
6.95k
  if (read % inputsPerCompaction == 0) {
120
    // after the compiler is let go, where that happened, so that the types
121
    // only it was holding are released too
122
108
    hobbes::compactMTypeMemory();
123
108
  }
124
6.95k
}
125
126
} // namespace
127
128
4
extern "C" int LLVMFuzzerInitialize(int*, char***) {
129
4
  compiler();
130
4
  return 0;
131
4
}
132
133
17.7k
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
134
17.7k
  std::string src(reinterpret_cast<const char*>(data), size);
135
17.7k
  try {
136
17.7k
    compiler().readExpr(src);
137
17.7k
  } catch (const std::exception&) {
138
    // rejecting malformed source is the expected behavior
139
14.2k
  }
140
17.7k
  reclaimPeriodically();
141
17.7k
  return 0;
142
17.7k
}