diff --git a/build.zig b/build.zig index da86d1e..de811f3 100644 --- a/build.zig +++ b/build.zig @@ -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"); } diff --git a/docs/journal.md b/docs/journal.md index aa12f1f..a64e1e4 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -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. + diff --git a/fuzz/README.md b/fuzz/README.md index 8e328e7..1f0194b 100644 --- a/fuzz/README.md +++ b/fuzz/README.md @@ -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. diff --git a/scripts/run-libfuzzer.sh b/scripts/run-libfuzzer.sh new file mode 100644 index 0000000..8afaf5c --- /dev/null +++ b/scripts/run-libfuzzer.sh @@ -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 +#include +#include +#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 diff --git a/src/parser.zig b/src/parser.zig index 153a151..64481d3 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -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(); @@ -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))) { @@ -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 @@ -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")) { @@ -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); diff --git a/tests/port/parser_leak.zig b/tests/port/parser_leak.zig new file mode 100644 index 0000000..a968909 --- /dev/null +++ b/tests/port/parser_leak.zig @@ -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); +} diff --git a/tests/port/root.zig b/tests/port/root.zig index 82cd5c2..320f134 100644 --- a/tests/port/root.zig +++ b/tests/port/root.zig @@ -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;