From f0a86ef2c2bc352a479162cca79d1aa64598f09e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 18 Sep 2026 03:31:42 +0200 Subject: [PATCH] feat(ci): seeded fuzz leg, and register both new legs as canonical entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/fuzz.sh drives the libFuzzer targets in two shapes from one entry: a fixed --runs from a fixed --seed, so a CI smoke is a function of the code and not a lottery (O9), and a time-boxed --max-total-time for exploration. Checked-in seeds are copied into a working corpus under build/fuzz/, so a run never rewrites the repository; a crash leaves its reproducer behind and the script exits non-zero. Wired as a smoke into dry-run.yml and as exploration into nightly-soak.yml. The venue-parity contract rightly refused both workflow steps: a venue may provision or plumb, but only a CANONICAL leg entry may exercise the product, and neither script was registered. Registering them — rather than working around the contract — is the fix, since both own their leg end to end (build, corpus or binary, artifact paths, exit code) exactly as soak-legs.sh owns the soak. _memwaste.yml joins the policed venue walk for the same reason, and both scripts join the layer-5 interface probes. Those probes immediately earned their keep, finding two real defects: `fuzz.sh --help` exited 2 because the positional target consumed the flag, and memwaste.sh read a typo'd flag as the repository path — which would have indexed nothing and reported an empty lane as a clean one. test_shell_line_endings.sh now degrades where the checkout has no reachable repository metadata: the Linux container bind-mounts the working tree, and a git worktree's .git is a pointer to the host repository, so `git ls-files` cannot run there and the contract failed with "matched no files — the glob set is broken", killing the leg before it compiled. It is a repository property, so it stays gating in every venue that has the metadata (macOS host, the Windows VM's real checkout at C:\cbm with 117 files, every hosted-CI checkout). test_version_metadata_contract.sh already guards the same way for the same reason; the alternatives considered and rejected are recorded at the case. Signed-off-by: Martin Vogel --- .github/workflows/dry-run.yml | 36 +++++ .github/workflows/nightly-soak.yml | 21 +++ scripts/fuzz.sh | 84 ++++++++++ tests/fuzz/cbm_fuzz.c | 186 ++++++++++++++++++++++ tests/fuzz/corpus/config/json-empty.seed | 1 + tests/fuzz/corpus/config/json.seed | 6 + tests/fuzz/corpus/config/toml.seed | 6 + tests/fuzz/corpus/config/yaml.seed | 5 + tests/fuzz/corpus/cypher/agg.seed | 1 + tests/fuzz/corpus/cypher/hops.seed | 1 + tests/fuzz/corpus/cypher/match.seed | 1 + tests/fuzz/corpus/cypher/optional.seed | 1 + tests/fuzz/corpus/cypher/union.seed | 1 + tests/fuzz/corpus/cypher/where.seed | 1 + tests/fuzz/corpus/extract/c.seed | 11 ++ tests/fuzz/corpus/extract/cpp.seed | 10 ++ tests/fuzz/corpus/extract/csharp.seed | 6 + tests/fuzz/corpus/extract/go.seed | 13 ++ tests/fuzz/corpus/extract/java.seed | 9 ++ tests/fuzz/corpus/extract/javascript.seed | 4 + tests/fuzz/corpus/extract/makefile.seed | 5 + tests/fuzz/corpus/extract/php.seed | 7 + tests/fuzz/corpus/extract/python.seed | 13 ++ tests/fuzz/corpus/extract/ruby.seed | 7 + tests/fuzz/corpus/extract/rust.seed | 10 ++ tests/fuzz/corpus/extract/sql.seed | 3 + tests/fuzz/corpus/extract/typescript.seed | 9 ++ tests/fuzz/corpus/extract/yaml.seed | 7 + tests/test_shell_line_endings.sh | 20 +++ tests/test_venue_parity_contract.sh | 12 +- 30 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 scripts/fuzz.sh create mode 100644 tests/fuzz/cbm_fuzz.c create mode 100644 tests/fuzz/corpus/config/json-empty.seed create mode 100644 tests/fuzz/corpus/config/json.seed create mode 100644 tests/fuzz/corpus/config/toml.seed create mode 100644 tests/fuzz/corpus/config/yaml.seed create mode 100644 tests/fuzz/corpus/cypher/agg.seed create mode 100644 tests/fuzz/corpus/cypher/hops.seed create mode 100644 tests/fuzz/corpus/cypher/match.seed create mode 100644 tests/fuzz/corpus/cypher/optional.seed create mode 100644 tests/fuzz/corpus/cypher/union.seed create mode 100644 tests/fuzz/corpus/cypher/where.seed create mode 100644 tests/fuzz/corpus/extract/c.seed create mode 100644 tests/fuzz/corpus/extract/cpp.seed create mode 100644 tests/fuzz/corpus/extract/csharp.seed create mode 100644 tests/fuzz/corpus/extract/go.seed create mode 100644 tests/fuzz/corpus/extract/java.seed create mode 100644 tests/fuzz/corpus/extract/javascript.seed create mode 100644 tests/fuzz/corpus/extract/makefile.seed create mode 100644 tests/fuzz/corpus/extract/php.seed create mode 100644 tests/fuzz/corpus/extract/python.seed create mode 100644 tests/fuzz/corpus/extract/ruby.seed create mode 100644 tests/fuzz/corpus/extract/rust.seed create mode 100644 tests/fuzz/corpus/extract/sql.seed create mode 100644 tests/fuzz/corpus/extract/typescript.seed create mode 100644 tests/fuzz/corpus/extract/yaml.seed diff --git a/.github/workflows/dry-run.yml b/.github/workflows/dry-run.yml index 4cf2ec46c..600dbad04 100644 --- a/.github/workflows/dry-run.yml +++ b/.github/workflows/dry-run.yml @@ -45,6 +45,10 @@ on: description: 'Skip candidate scan; mark unscanned and default to stripped' type: boolean default: false + skip_memwaste: + description: 'Skip the waste sanitizer (report-only) and the fuzz smoke' + type: boolean + default: false soak_level: description: 'Soak: full (quick+asan), quick (10min), none' type: choice @@ -107,6 +111,38 @@ jobs: with: broad_platforms: true + # ── Waste sanitizer, REPORT-ONLY (independent island) ───────── + # Event lane on Linux, macOS and Windows, access lane and the per-function + # scaling lane on Linux. Uploads reports; fails only when the measurement is + # unsound (a defect of the tool itself). + memwaste: + if: ${{ inputs.skip_memwaste != true }} + uses: ./.github/workflows/_memwaste.yml + with: + mode: full + + # ── Fuzz smoke (independent island) ─────────────────────────── + # A fixed number of executions from a fixed seed per target, so the verdict is + # a function of the commit (O9). A crash here is a real defect in a parser + # that reads untrusted input; its reproducer is uploaded. + fuzz: + if: ${{ inputs.skip_memwaste != true }} + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install deps + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev + - name: Fuzz smoke (extract, cypher, config) + run: scripts/fuzz.sh all --runs 20000 --seed 1 + - name: Upload reproducers + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-artifacts + path: build/fuzz/artifacts-*/ + if-no-files-found: ignore + # ── Soak tests (optional, parallel with smoke) ──────────────── soak: if: ${{ inputs.soak_level != 'none' && !cancelled() && needs.build.result != 'failure' && needs.build.result != 'skipped' }} diff --git a/.github/workflows/nightly-soak.yml b/.github/workflows/nightly-soak.yml index 4c8abeb28..2bd5bc7e8 100644 --- a/.github/workflows/nightly-soak.yml +++ b/.github/workflows/nightly-soak.yml @@ -35,6 +35,27 @@ permissions: contents: read jobs: + # Fuzz exploration: new inputs are the point here, so it runs for a time + # budget instead of a fixed count. NON-GATING like the soak; a crash uploads + # its reproducer, which becomes a fixed-seed regression input once fixed. + fuzz: + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install deps + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev clang llvm libclang-rt-dev + - name: Fuzz exploration (30 min per target) + run: scripts/fuzz.sh all --max-total-time 1800 --seed 0 + - name: Upload reproducers + if: ${{ failure() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: fuzz-artifacts-nightly + path: build/fuzz/artifacts-*/ + if-no-files-found: ignore + soak: uses: ./.github/workflows/_soak.yml with: diff --git a/scripts/fuzz.sh b/scripts/fuzz.sh new file mode 100644 index 000000000..43e44d8f9 --- /dev/null +++ b/scripts/fuzz.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# fuzz.sh — run the libFuzzer targets of build/fuzz/cbm-fuzz. +# +# Usage: +# scripts/fuzz.sh [--runs N] [--max-total-time S] +# [--jobs N] [--seed N] [--no-build] +# +# Two shapes, one driver: +# --runs N --seed S a fixed number of executions from a fixed seed: the same +# inputs every time, so a smoke in CI is a function of the +# code (O9). This is the default (--runs 20000 --seed 1). +# --max-total-time S exploration for dry-run and nightly: new inputs are the +# point, and a crash is saved with its reproducer. +# +# The checked-in seeds (tests/fuzz/corpus//) are copied into a working +# corpus under build/fuzz/, so a run never rewrites the repository. A crash, +# leak or timeout leaves its input in build/fuzz/artifacts-/ and the +# script exits non-zero; reproduce with +# CBM_FUZZ_TARGET= build/fuzz/cbm-fuzz +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# --help before the positional target: the venue-parity interface probe calls +# every canonical entry as ` --help` with no other argument. +case "${1:-}" in +-h | --help) sed -n '2,21p' "$0"; exit 0 ;; +esac +TARGET="${1:-}" +[ $# -gt 0 ] && shift +RUNS=20000 +SEED=1 +MAX_TIME="" +JOBS=1 +BUILD=1 +while [ $# -gt 0 ]; do + case "$1" in + --runs) RUNS="$2"; shift 2 ;; + --seed) SEED="$2"; shift 2 ;; + --max-total-time) MAX_TIME="$2"; RUNS=""; shift 2 ;; + --jobs) JOBS="$2"; shift 2 ;; + --no-build) BUILD=0; shift ;; + -h | --help) sed -n '2,21p' "$0"; exit 0 ;; + *) echo "fuzz.sh: unknown argument $1. Please consult --help." >&2; exit 2 ;; + esac +done + +case "$TARGET" in +extract | cypher | config) TARGETS=("$TARGET") ;; +all) TARGETS=(extract cypher config) ;; +*) echo "fuzz.sh: target must be extract, cypher, config or all. Please consult --help." >&2 + exit 2 ;; +esac + +BIN="$ROOT/build/fuzz/cbm-fuzz" +if [ "$BUILD" -eq 1 ]; then + make -C "$ROOT" -f Makefile.cbm -j"$(getconf _NPROCESSORS_ONLN)" fuzz >/dev/null +fi +if [ ! -x "$BIN" ]; then + echo "fuzz.sh: $BIN is missing (build failed, or --no-build without a build)" >&2 + exit 2 +fi + +status=0 +for t in "${TARGETS[@]}"; do + corpus="$ROOT/build/fuzz/corpus-$t" + artifacts="$ROOT/build/fuzz/artifacts-$t/" + mkdir -p "$corpus" "$artifacts" + cp -R "$ROOT/tests/fuzz/corpus/$t/." "$corpus/" + args=(-artifact_prefix="$artifacts" -seed="$SEED" -rss_limit_mb=4096 -timeout=30 -print_final_stats=1) + if [ -n "$RUNS" ]; then + args+=(-runs="$RUNS") + else + args+=(-max_total_time="$MAX_TIME") + fi + if [ "$JOBS" -gt 1 ]; then + args+=(-jobs="$JOBS" -workers="$JOBS") + fi + echo "=== fuzz $t: ${RUNS:+$RUNS runs}${MAX_TIME:+$MAX_TIME s} (seed $SEED) ===" + if ! CBM_FUZZ_TARGET="$t" "$BIN" "${args[@]}" "$corpus"; then + echo "fuzz.sh: target $t FAILED -- reproducer in $artifacts" >&2 + status=1 + fi +done +exit "$status" diff --git a/tests/fuzz/cbm_fuzz.c b/tests/fuzz/cbm_fuzz.c new file mode 100644 index 000000000..fa6df2c92 --- /dev/null +++ b/tests/fuzz/cbm_fuzz.c @@ -0,0 +1,186 @@ +/* + * cbm_fuzz.c — coverage-guided fuzzing of the parsers that read untrusted bytes. + * + * One libFuzzer binary, three targets, chosen by CBM_FUZZ_TARGET: + * + * extract "\n": the language comes from the file name + * exactly as discovery would pick it, the source goes through the + * full per-file extraction (tree-sitter grammar, extractors, LSP + * passes) and the result is freed. Every grammar and extractor is + * reachable from one corpus. + * cypher the whole input is a query string for the lexer and parser. + * config "": the document is written to a private file + * and one managed-entry upsert plus remove runs on it through the + * TOML, YAML or JSON editor -- the code that rewrites a user's own + * agent configuration. + * + * Built by `make -f Makefile.cbm fuzz` (clang with -fsanitize=fuzzer on top of + * ASan+UBSan; every vendored object is compiled with fuzzer-no-link, so the + * grammars feed coverage back too). Driven by scripts/fuzz.sh, which seeds each + * target from tests/fuzz/corpus//. + */ +#include "../../internal/cbm/cbm.h" +#include "../../src/cli/config_json_like.h" +#include "../../src/cli/config_toml_edit.h" +#include "../../src/cli/config_yaml_edit.h" +#include "../../src/cypher/cypher.h" +#include "../../src/discover/discover.h" +#include "../../src/foundation/compat_fs.h" + +#include +#include +#include +#include +#include + +enum { FUZZ_EXTRACT, FUZZ_CYPHER, FUZZ_CONFIG }; +enum { + FUZZ_NAME_MAX = 128, + FUZZ_EXTRACT_BUDGET_MICROS = 2000000, /* a hang is a finding; a slow parse is not */ + FUZZ_PATH_MAX = 512, + FUZZ_EXIT_SETUP = 2, /* the harness itself could not start */ + FUZZ_NUL = 1, /* room for a terminator; also the newline after the name */ + FUZZ_JSON_PATH_LEN = 1, + FUZZ_FILE_NAME_ROOM = 32, /* "/config.toml" and a terminator */ +}; + +static int g_target = FUZZ_EXTRACT; +static char g_dir[FUZZ_PATH_MAX]; + +int LLVMFuzzerInitialize(int *argc, char ***argv) { + (void)argc; + (void)argv; + const char *t = getenv("CBM_FUZZ_TARGET"); + if (t && strcmp(t, "cypher") == 0) { + g_target = FUZZ_CYPHER; + } else if (t && strcmp(t, "config") == 0) { + g_target = FUZZ_CONFIG; + } else if (t && strcmp(t, "extract") != 0) { + (void)fprintf(stderr, "cbm_fuzz: CBM_FUZZ_TARGET must be extract, cypher or config\n"); + exit(FUZZ_EXIT_SETUP); + } + if (g_target == FUZZ_EXTRACT && cbm_init() != 0) { + (void)fprintf(stderr, "cbm_fuzz: cbm_init failed\n"); + exit(FUZZ_EXIT_SETUP); + } + if (g_target == FUZZ_CONFIG) { + const char *tmp = getenv("TMPDIR"); + (void)snprintf(g_dir, sizeof(g_dir), "%s/cbm-fuzz-XXXXXX", tmp && tmp[0] ? tmp : "/tmp"); + if (!mkdtemp(g_dir)) { + perror("cbm_fuzz: mkdtemp"); + exit(FUZZ_EXIT_SETUP); + } + } + return 0; +} + +static void fuzz_extract(const uint8_t *data, size_t size) { + size_t scan = size < FUZZ_NAME_MAX ? size : FUZZ_NAME_MAX; + const uint8_t *nl = memchr(data, '\n', scan); + if (!nl || nl == data) { + return; + } + size_t name_len = (size_t)(nl - data); + char name[FUZZ_NAME_MAX + FUZZ_NUL]; + memcpy(name, data, name_len); + name[name_len] = '\0'; + CBMLanguage lang = cbm_language_for_filename(name); + if ((int)lang < 0 || lang >= CBM_LANG_COUNT) { + return; + } + size_t src_len = size - name_len - FUZZ_NUL; + if (src_len > (size_t)INT32_MAX) { + return; + } + /* The pipeline hands extraction a file buffer with a terminator after + * source_len bytes, and parts of the LSP passes rely on it (strlen over the + * source). Fuzz the contract production honours, not a stricter one. */ + char *source = malloc(src_len + FUZZ_NUL); + if (!source) { + return; + } + memcpy(source, nl + FUZZ_NUL, src_len); + source[src_len] = '\0'; + CBMFileResult *r = cbm_extract_file(source, (int)src_len, lang, "fuzz", name, + FUZZ_EXTRACT_BUDGET_MICROS, NULL, NULL); + if (r) { + cbm_free_tree(r); + cbm_free_result(r); + } + free(source); +} + +static void fuzz_cypher(const uint8_t *data, size_t size) { + char *query = malloc(size + FUZZ_NUL); + if (!query) { + return; + } + memcpy(query, data, size); + query[size] = '\0'; + cbm_query_t *q = NULL; + char *error = NULL; + (void)cbm_cypher_parse(query, &q, &error); + cbm_query_free(q); + free(error); + free(query); +} + +static void fuzz_config(const uint8_t *data, size_t size) { + if (size < FUZZ_NUL) { + return; + } + char path[FUZZ_PATH_MAX + FUZZ_FILE_NAME_ROOM]; + const char *ext = data[0] == 't' ? "toml" : data[0] == 'y' ? "yaml" : "json"; + (void)snprintf(path, sizeof(path), "%s/config.%s", g_dir, ext); + FILE *f = cbm_fopen(path, "wb"); + if (!f) { + return; + } + size_t body = size - FUZZ_NUL; + if (body > 0 && fwrite(data + FUZZ_NUL, FUZZ_NUL, body, f) != body) { + (void)fclose(f); + return; + } + if (fclose(f) != 0) { + return; + } + switch (data[0]) { + case 't': + (void)cbm_toml_upsert_managed_block(path, "# BEGIN codebase-memory", "# END codebase-memory", + "owned = true\n"); + (void)cbm_toml_remove_managed_block(path, "# BEGIN codebase-memory", "# END codebase-memory"); + break; + case 'y': + (void)cbm_yaml_upsert_mapping_entry(path, "mcp_servers", "codebase-memory", + " command: codebase-memory-mcp\n"); + (void)cbm_yaml_remove_mapping_entry(path, "mcp_servers", "codebase-memory"); + break; + default: { + const char *object_path[] = {"mcpServers"}; + (void)cbm_json_like_upsert_entry(path, object_path, FUZZ_JSON_PATH_LEN, "codebase-memory", + "{\"command\":\"codebase-memory-mcp\"}"); + char *content = NULL; + size_t length = 0; + if (cbm_json_like_read_document(path, &content, &length) == 0) { + free(content); + } + break; + } + } + (void)unlink(path); +} + +int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + switch (g_target) { + case FUZZ_CYPHER: + fuzz_cypher(data, size); + break; + case FUZZ_CONFIG: + fuzz_config(data, size); + break; + default: + fuzz_extract(data, size); + break; + } + return 0; +} diff --git a/tests/fuzz/corpus/config/json-empty.seed b/tests/fuzz/corpus/config/json-empty.seed new file mode 100644 index 000000000..0e4721e84 --- /dev/null +++ b/tests/fuzz/corpus/config/json-empty.seed @@ -0,0 +1 @@ +j{} \ No newline at end of file diff --git a/tests/fuzz/corpus/config/json.seed b/tests/fuzz/corpus/config/json.seed new file mode 100644 index 000000000..3f248defa --- /dev/null +++ b/tests/fuzz/corpus/config/json.seed @@ -0,0 +1,6 @@ +j{ + "mcpServers": { + "other": {"command": "other"} + }, + "theme": "dark" +} diff --git a/tests/fuzz/corpus/config/toml.seed b/tests/fuzz/corpus/config/toml.seed new file mode 100644 index 000000000..2d8091a08 --- /dev/null +++ b/tests/fuzz/corpus/config/toml.seed @@ -0,0 +1,6 @@ +t[mcp_servers.other] +command = "other" + +# BEGIN codebase-memory +old = true +# END codebase-memory diff --git a/tests/fuzz/corpus/config/yaml.seed b/tests/fuzz/corpus/config/yaml.seed new file mode 100644 index 000000000..105cc400c --- /dev/null +++ b/tests/fuzz/corpus/config/yaml.seed @@ -0,0 +1,5 @@ +ymcp_servers: + other: + command: other + codebase-memory: + command: old diff --git a/tests/fuzz/corpus/cypher/agg.seed b/tests/fuzz/corpus/cypher/agg.seed new file mode 100644 index 000000000..1e0fdd6c7 --- /dev/null +++ b/tests/fuzz/corpus/cypher/agg.seed @@ -0,0 +1 @@ +MATCH (m:Module)<-[:DEFINES]-(f:File) WITH m, count(f) AS c WHERE c > 2 RETURN m.name, c ORDER BY c DESC LIMIT 10 \ No newline at end of file diff --git a/tests/fuzz/corpus/cypher/hops.seed b/tests/fuzz/corpus/cypher/hops.seed new file mode 100644 index 000000000..43388715e --- /dev/null +++ b/tests/fuzz/corpus/cypher/hops.seed @@ -0,0 +1 @@ +MATCH p = (a:Class)-[:INHERITS*1..3]->(b:Class) RETURN a.name, length(p) \ No newline at end of file diff --git a/tests/fuzz/corpus/cypher/match.seed b/tests/fuzz/corpus/cypher/match.seed new file mode 100644 index 000000000..e0f1e4080 --- /dev/null +++ b/tests/fuzz/corpus/cypher/match.seed @@ -0,0 +1 @@ +MATCH (n:Function) RETURN n.name LIMIT 5 \ No newline at end of file diff --git a/tests/fuzz/corpus/cypher/optional.seed b/tests/fuzz/corpus/cypher/optional.seed new file mode 100644 index 000000000..caddb1ba4 --- /dev/null +++ b/tests/fuzz/corpus/cypher/optional.seed @@ -0,0 +1 @@ +MATCH (f:Function) OPTIONAL MATCH (f)-[:CALLS]->(g) RETURN f.name, collect(g.name) \ No newline at end of file diff --git a/tests/fuzz/corpus/cypher/union.seed b/tests/fuzz/corpus/cypher/union.seed new file mode 100644 index 000000000..00c528587 --- /dev/null +++ b/tests/fuzz/corpus/cypher/union.seed @@ -0,0 +1 @@ +MATCH (n:Function) RETURN n.name AS name UNION MATCH (n:Method) RETURN n.name AS name \ No newline at end of file diff --git a/tests/fuzz/corpus/cypher/where.seed b/tests/fuzz/corpus/cypher/where.seed new file mode 100644 index 000000000..560d106c3 --- /dev/null +++ b/tests/fuzz/corpus/cypher/where.seed @@ -0,0 +1 @@ +MATCH (f:Function)-[:CALLS]->(g:Function) WHERE f.name = 'main' AND g.file_path CONTAINS 'src' RETURN g.name, g.file_path ORDER BY g.name \ No newline at end of file diff --git a/tests/fuzz/corpus/extract/c.seed b/tests/fuzz/corpus/extract/c.seed new file mode 100644 index 000000000..992cc4c3f --- /dev/null +++ b/tests/fuzz/corpus/extract/c.seed @@ -0,0 +1,11 @@ +store.c +#include +#include "store.h" + +struct store { int n; char *buf; }; + +struct store *store_new(int n) { + struct store *s = malloc(sizeof(*s)); + s->n = n; + return s; +} diff --git a/tests/fuzz/corpus/extract/cpp.seed b/tests/fuzz/corpus/extract/cpp.seed new file mode 100644 index 000000000..d8cd0d401 --- /dev/null +++ b/tests/fuzz/corpus/extract/cpp.seed @@ -0,0 +1,10 @@ +graph.cpp +#include +namespace g { +template class Graph { +public: + void add(T v) { nodes_.push_back(v); } +private: + std::vector nodes_; +}; +} diff --git a/tests/fuzz/corpus/extract/csharp.seed b/tests/fuzz/corpus/extract/csharp.seed new file mode 100644 index 000000000..31dd6b231 --- /dev/null +++ b/tests/fuzz/corpus/extract/csharp.seed @@ -0,0 +1,6 @@ +Program.cs +using System; +namespace App { + public interface IRepo { string Get(int id); } + public class Repo : IRepo { public string Get(int id) => id.ToString(); } +} diff --git a/tests/fuzz/corpus/extract/go.seed b/tests/fuzz/corpus/extract/go.seed new file mode 100644 index 000000000..51cc74b67 --- /dev/null +++ b/tests/fuzz/corpus/extract/go.seed @@ -0,0 +1,13 @@ +main.go +package main + +import "fmt" + +type Server struct{ name string } + +func (s *Server) Run() error { + fmt.Println(s.name) + return nil +} + +func main() { (&Server{name: "x"}).Run() } diff --git a/tests/fuzz/corpus/extract/java.seed b/tests/fuzz/corpus/extract/java.seed new file mode 100644 index 000000000..f12986812 --- /dev/null +++ b/tests/fuzz/corpus/extract/java.seed @@ -0,0 +1,9 @@ +Main.java +package app; + +import java.util.List; + +public class Main { + interface Repo { List all(); } + public static void main(String[] args) { System.out.println(args.length); } +} diff --git a/tests/fuzz/corpus/extract/javascript.seed b/tests/fuzz/corpus/extract/javascript.seed new file mode 100644 index 000000000..c5ecbd417 --- /dev/null +++ b/tests/fuzz/corpus/extract/javascript.seed @@ -0,0 +1,4 @@ +index.js +const http = require('http'); +function handler(req, res) { res.end(req.url); } +module.exports = () => http.createServer(handler).listen(8080); diff --git a/tests/fuzz/corpus/extract/makefile.seed b/tests/fuzz/corpus/extract/makefile.seed new file mode 100644 index 000000000..91c16d0dc --- /dev/null +++ b/tests/fuzz/corpus/extract/makefile.seed @@ -0,0 +1,5 @@ +Makefile +CC ?= cc +all: app +app: main.o + $(CC) -o $@ $^ diff --git a/tests/fuzz/corpus/extract/php.seed b/tests/fuzz/corpus/extract/php.seed new file mode 100644 index 000000000..ac1c6be72 --- /dev/null +++ b/tests/fuzz/corpus/extract/php.seed @@ -0,0 +1,7 @@ +index.php + Option<&String>; } + +pub struct Mem { m: HashMap } + +impl Store for Mem { + fn get(&self, k: &str) -> Option<&String> { self.m.get(k) } +} diff --git a/tests/fuzz/corpus/extract/sql.seed b/tests/fuzz/corpus/extract/sql.seed new file mode 100644 index 000000000..3b5208fcd --- /dev/null +++ b/tests/fuzz/corpus/extract/sql.seed @@ -0,0 +1,3 @@ +schema.sql +CREATE TABLE nodes (id INTEGER PRIMARY KEY, name TEXT NOT NULL); +SELECT name FROM nodes WHERE id = 1; diff --git a/tests/fuzz/corpus/extract/typescript.seed b/tests/fuzz/corpus/extract/typescript.seed new file mode 100644 index 000000000..a57b5d35a --- /dev/null +++ b/tests/fuzz/corpus/extract/typescript.seed @@ -0,0 +1,9 @@ +svc.ts +import { readFile } from 'fs/promises'; + +export interface Repo { get(id: string): Promise; } + +export class FileRepo implements Repo { + constructor(private dir: string) {} + async get(id: string) { return readFile(`${this.dir}/${id}`, 'utf8'); } +} diff --git a/tests/fuzz/corpus/extract/yaml.seed b/tests/fuzz/corpus/extract/yaml.seed new file mode 100644 index 000000000..06a2881d2 --- /dev/null +++ b/tests/fuzz/corpus/extract/yaml.seed @@ -0,0 +1,7 @@ +deploy.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 2 diff --git a/tests/test_shell_line_endings.sh b/tests/test_shell_line_endings.sh index a6f3fc387..1bc38c1c6 100644 --- a/tests/test_shell_line_endings.sh +++ b/tests/test_shell_line_endings.sh @@ -12,6 +12,26 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" +# This contract reads REPOSITORY metadata (.gitattributes, through git's own +# attribute matcher). The Linux container leg mounts the WORKING TREE only, and +# a git worktree's .git is a file pointing at the host's main repository, which +# is not inside the container — so `git ls-files` cannot run there at all +# (verified 2026-09-17: `fatal: not a git repository`). The property is +# platform-independent and stays gated in every venue that has the metadata: +# the macOS host leg, the Windows VM (a real checkout at C:\cbm — 117 files +# checked there), and every hosted-CI checkout. +# Tried and rejected: mounting the host .git into the container — the worktree +# gitdir path would have to be reproduced inside, and container-root writes to +# the host repository are worse than a scoped skip; and matching .gitattributes +# patterns ourselves, which is a partial copy of git's attribute semantics and +# so a false-green risk. tests/test_version_metadata_contract.sh guards the +# same way for the same reason. +if ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "SKIP: no repository metadata in this checkout (working tree without" \ + "its git dir) — the line-ending contract gates on the host leg and CI" + exit 0 +fi + failures=0 checked=0 while IFS= read -r -d '' path && diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh index 4bcd694d7..81e431a1e 100755 --- a/tests/test_venue_parity_contract.sh +++ b/tests/test_venue_parity_contract.sh @@ -47,7 +47,11 @@ failures: list[str] = [] # ── The canonical leg entries (the ONLY product-exercising calls allowed) ── CANONICAL = re.compile( - r"scripts/(test|build|lint|clean|smoke-local|soak-legs|smoke-invariants|package-release)\.sh" + # fuzz.sh and memwaste.sh own their legs end to end (build, corpus/binary, + # artifact paths, exit code), exactly as soak-legs.sh owns the soak — the + # venue passes shape flags and collects the report, nothing more. + r"scripts/(test|build|lint|clean|smoke-local|soak-legs|smoke-invariants" + r"|package-release|fuzz|memwaste)\.sh" r"|test-infrastructure/vm/vm-smoke\.sh" r"|scripts/ci/[a-z0-9-]+\.(sh|ps1|py)" r"|scripts/security-[a-z0-9-]+\.sh" @@ -78,7 +82,7 @@ FORBIDDEN = [ VENUE_WORKFLOWS = [ "pr.yml", "dry-run.yml", "release.yml", "nightly-soak.yml", "_test.yml", "_smoke.yml", "_soak.yml", "_build.yml", "_lint.yml", - "smoke.yml", + "smoke.yml", "_memwaste.yml", ] # Provisioning / plumbing commands a venue may run (first word of a line). @@ -391,6 +395,8 @@ scripts/ci/verify-shard-union.sh scripts/ci/generate-sbom.py scripts/package-release.sh scripts/ci/smoke-artifact.sh +scripts/fuzz.sh +scripts/memwaste.sh test-infrastructure/run.sh test-infrastructure/vm/vm-smoke.sh test-infrastructure/vm/vm-run-tests.sh @@ -428,6 +434,8 @@ scripts/soak-legs.sh scripts/ci/preflight-docker.sh test-infrastructure/vm/vm-smoke.sh scripts/smoke-invariants.sh +scripts/fuzz.sh +scripts/memwaste.sh " for entry in $STRICT_ENTRIES; do out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$?