Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ pub fn build(b: *std.Build) void {
});
const run_harness_tests = b.addRunArtifact(harness_tests);
fuzz_step.dependOn(&run_harness_tests.step);
// Also attach to the default test step so `zig build test --fuzz` sees Smith cases.
test_step.dependOn(&run_harness_tests.step);

_ = b.step("demo", "Prompt 9: demo assets");
}
10 changes: 10 additions & 0 deletions docs/journal.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,13 @@ input byte `0x80`; UBSan silent on glibc. Lead C (locale): under
hang on `.5` not reproduced within 3s; smoke under env-only LC_ALL stays C
locale.

## 2026-08-02 elmengo parser-leak-from-fuzz

`zig build test -Doptimize=ReleaseSafe --fuzz` found DebugAllocator leaks on
parse-error paths after a successful left operand (unary minus / binary op).
Root cause: `list`/`expr`/`term`/`factor`/`power` lacked `errdefer destroy` on
the accumulating `ret` node when a later `rhs` parse failed. Fixed in
`src/parser.zig`. Regression: `tests/port/parser_leak.zig` (cases `-1+`,
`1+`, etc.). Original suite still 4930/4930. PR #4 already merged the
allocfault/fuzz/sanitizer chunk; this fix follows as a separate PR.

34 changes: 34 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,37 @@ Artifacts:

- `corpus-seed/`: small tracked seed inputs.
- `logs/`: gitignored runtime logs from `scripts/run-logged.sh`.

## Differential fuzz (Prompt 5)

```bash
bash scripts/run-fuzz.sh 60 fuzz/log.txt certified # required 60s log
bash scripts/run-fuzz.sh 14400 fuzz/log-long.txt long
bash scripts/run-metamorphic.sh
bash scripts/run-libfuzzer.sh 30 fuzz/log-libfuzzer.txt
zig build test -Doptimize=ReleaseSafe --fuzz # Zig 0.16 built-in; not Debug
```

`fuzz/harness.c` compares C (objcopy-renamed) vs Zig on result bits, error
position, success/NULL, and structured near-valid inputs. `fuzz/log.txt` is
the certified sixty-second run. Wall-clock is secondary; executions and
covered branches are the primary metrics.

### Corpora tracked in git

| Path | Tracked? | Why |
| --- | --- | --- |
| `fuzz/corpus-seed/` | yes | small seeds that reach builtins / errors |
| `tests/port/fixtures/*.jsonl` | yes | minimised coverage / eval fixtures |
| `.local/fuzz/` raw queues | no | regenerable, can grow large |

## Allocation-failure injection

```bash
bash tools/allocfault/run.sh
ALLOCFAULT_ASAN=1 bash tools/allocfault/run.sh
```

One LD_PRELOAD interposer works for both C and Zig because both allocate
through libc `malloc` at the ABI boundary. ASan ordinal runs use
`tools/allocfault/tinyexpr_instrumented.c` on the C side.
80 changes: 80 additions & 0 deletions scripts/run-libfuzzer.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/bin/bash
# Second differential engine: libFuzzer over the same C-vs-Zig compare.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$ROOT/.local/fuzz"
CACHE="${CACHE:-$HOME/.cache/zig-tinyexpr}"
export PATH="$HOME/.local/zig:/opt/zig:$PATH"
SECONDS_ARG="${1:-30}"
OUT_LOG="${2:-$ROOT/fuzz/log-libfuzzer.txt}"
mkdir -p "$WORK" "$(dirname "$OUT_LOG")"

if ! command -v clang >/dev/null; then
echo "clang not found" >&2
exit 2
fi

# Reuse renamed oracle + Zig archive from run-fuzz if missing.
if [[ ! -f $WORK/tinyexpr-c-renamed.o || ! -f $WORK/zig-out/lib/libtinyexpr.a ]]; then
bash "$ROOT/scripts/run-fuzz.sh" 1 "$WORK/warmup-libfuzzer.txt" warmup-libfuzzer >/dev/null
fi

cat >"$WORK/libfuzzer_driver.c" <<'EOF'
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include "tinyexpr.h"
extern te_expr *c_te_compile(const char *, const te_variable *, int, int *);
extern double c_te_eval(const te_expr *);
extern void c_te_free(te_expr *);
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
if (size == 0 || size > 96) return 0;
char buf[97];
memcpy(buf, data, size);
buf[size] = 0;
for (size_t i = 0; i < size; i++)
if (buf[i] == 0) buf[i] = ' ';
int ce = 0, ze = 0;
te_expr *c = c_te_compile(buf, 0, 0, &ce);
te_expr *z = te_compile(buf, 0, 0, &ze);
if ((c == NULL) != (z == NULL) || ce != ze) __builtin_trap();
if (c && z) {
double cv = c_te_eval(c), zv = te_eval(z);
uint64_t cb, zb;
memcpy(&cb, &cv, 8);
memcpy(&zb, &zv, 8);
if (cb != zb) __builtin_trap();
c_te_free(c);
te_free(z);
} else {
if (c) c_te_free(c);
if (z) te_free(z);
}
return 0;
}
EOF

clang -O1 -fsanitize=fuzzer -I"$ROOT/third_party/tinyexpr-c" \
"$WORK/libfuzzer_driver.c" "$WORK/tinyexpr-c-renamed.o" \
"$WORK/zig-out/lib/libtinyexpr.a" "$ROOT/tools/zig_probe_stub.c" \
-lm -o "$WORK/libfuzzer_harness"

START="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
SHA="$(git -C "$ROOT" rev-parse HEAD)"
set +e
timeout $((SECONDS_ARG + 15)) "$WORK/libfuzzer_harness" -max_total_time="$SECONDS_ARG" \
-seed_inputs="$WORK/seed.txt" >"$WORK/libfuzzer-out.txt" 2>&1
STATUS=$?
set -e
END="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
{
echo "# libFuzzer differential log"
echo "command: bash scripts/run-libfuzzer.sh $SECONDS_ARG $OUT_LOG"
echo "git_sha: $SHA"
echo "started_at_utc: $START"
echo "completed_at_utc: $END"
echo "harness_exit: $STATUS"
echo "--- harness output (tail) ---"
tail -n 40 "$WORK/libfuzzer-out.txt"
} | tee "$OUT_LOG"
exit 0
5 changes: 5 additions & 0 deletions src/parser.zig
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ pub fn Parser(comptime Builder: type) type {
try self.enter();
defer self.leave();
var ret = try self.expr();
errdefer self.builder.destroy(&ret);
while (self.lex.token.kind == .sep) {
self.lex.nextToken();
var rhs = try self.expr();
Expand All @@ -117,6 +118,7 @@ pub fn Parser(comptime Builder: type) type {
try self.enter();
defer self.leave();
var ret = try self.term();
errdefer self.builder.destroy(&ret);
while (self.lex.token.kind == .infix and
(infixIs(self.lex.token.callee, .add) or infixIs(self.lex.token.callee, .sub)))
{
Expand All @@ -134,6 +136,7 @@ pub fn Parser(comptime Builder: type) type {
try self.enter();
defer self.leave();
var ret = try self.factor();
errdefer self.builder.destroy(&ret);
while (self.lex.token.kind == .infix and
(infixIs(self.lex.token.callee, .mul) or
infixIs(self.lex.token.callee, .divide) or
Expand All @@ -153,6 +156,7 @@ pub fn Parser(comptime Builder: type) type {
try self.enter();
defer self.leave();
var ret = try self.power();
errdefer self.builder.destroy(&ret);
if (options.pow_from_right) {
var neg: bool = false;
if (@hasDecl(Builder, "tryPeelNegate")) {
Expand Down Expand Up @@ -197,6 +201,7 @@ pub fn Parser(comptime Builder: type) type {
self.lex.nextToken();
}
var ret = try self.base();
errdefer self.builder.destroy(&ret);
if (sign < 0) {
var args = [_]Node{ret};
ret = try self.builder.createCall(builtin(.negate), &args);
Expand Down
19 changes: 19 additions & 0 deletions tests/port/parser_leak.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Regression: parser must free partial AST nodes when a later token errors.
//! Found by `zig build test -Doptimize=ReleaseSafe --fuzz` (Smith campaign).

const std = @import("std");
const tinyexpr = @import("tinyexpr");

test "parse error after unary minus does not leak (fuzz campaign)" {
var gpa = std.heap.GeneralPurposeAllocator(.{ .safety = true }){};
const a = gpa.allocator();
// Left operand builds a negate/constant node; trailing junk forces ParseError.
// Found by zig build test -Doptimize=ReleaseSafe --fuzz (Smith campaign).
const cases = [_][:0]const u8{ "-1+", "-1*", "-e", "1+", "(1+", "1+*" };
for (cases) |expr| {
var err: c_int = 0;
_ = tinyexpr.interp(a, expr.ptr, &err);
try std.testing.expect(err != 0);
}
try std.testing.expect(gpa.deinit() == .ok);
}
1 change: 1 addition & 0 deletions tests/port/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const tinyexpr = @import("tinyexpr");
const _error_positions = @import("error_positions.zig");
const _te_print = @import("te_print.zig");
const _native_abi_diff = @import("native_abi_diff.zig");
const _parser_leak = @import("parser_leak.zig");

const Variable = tinyexpr.Variable;

Expand Down
Loading