diff --git a/src/detect/probe.c b/src/detect/probe.c new file mode 100644 index 0000000..665a868 --- /dev/null +++ b/src/detect/probe.c @@ -0,0 +1,383 @@ +/* + * probe.c - configure-time probe snippet generator (todo 12). + * + * Emits POSIX-sh that probes a check AT CONFIGURE TIME, inside the + * generated ./configure. This module is a pure code generator: it never + * runs a compiler, never writes probe sources, never executes anything. All + * context (CC, cross_compiling, compiler id, cache/log/tmp paths) is shell + * variables established by the configure preamble (todo 16); see probe.h + * for the full contract, the cache-key scheme, and the emitted shape. + * + * The two load-bearing safety decisions: + * - The C source is embedded with `printf '%s' > + * "$st_probe_src"` — a single-quoted literal (not a here-document), so + * arbitrary bytes incl. newlines and quotes in the C source are inert. + * st_sh_quote (todo 15) is the ONLY safe embedder; everything else here + * is hand-written fixed shell text or identifier-validated names. + * - checkname is validated as a POSIX identifier before interpolation, + * because it lands in variable-NAME positions (have_, + * ac_cv__$st_cc_id, a temp filename) that cannot be quoted. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "detect/probe.h" + +#include "error.h" +#include "gen/sh_emit.h" + +#include +#include +#include +#include + +/* ---- small helpers ----------------------------------------------------- */ + +/* True when `s` is a valid POSIX shell identifier: letter/underscore, then + * letters/digits/underscores, non-empty. */ +static bool +valid_ident(const char *s) +{ + const unsigned char *p; + + if (s == NULL || *s == '\0') { + return false; + } + p = (const unsigned char *)s; + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + *p == '_')) { + return false; + } + for (p++; *p != '\0'; p++) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_')) { + return false; + } + } + return true; +} + +/* Write each argv element as a single-quoted literal, space-separated. + * Returns 0 on success. Returns -1 with *err set (ST_ERR_INTERNAL) when + * quoting fails (NUL byte / OOM); I/O errors are detected later via + * ferror(out) and are NOT reported here. */ +static int +emit_argv_quoted(FILE *out, char *const *argv, struct st_error **err) +{ + size_t i; + + for (i = 0; argv[i] != NULL; i++) { + char *q = st_sh_quote_ex(argv[i], err); + + if (q == NULL) { + if (err != NULL && *err == NULL) { + *err = st_error_internal("out of memory quoting probe argv"); + } + return -1; + } + if (i > 0) { + (void)fputc(' ', out); + } + (void)fputs(q, out); + free(q); + } + return 0; +} + +/* Emit a COMMAND probe's argv as a shell command. All elements except the + * LAST are hardcoded literals owned by checks.c (the program name "command" + * / "pkg-config" plus fixed flags like "-v"/"--cflags"/"--libs"), so they + * are written verbatim; the LAST element is the user-supplied target + * (program/package name) and is single-quoted. Returns 0, or -1 with *err + * set when quoting fails; I/O errors are caught via ferror. */ +static int +emit_command_argv(FILE *out, char *const *argv, struct st_error **err) +{ + size_t n = 0; + size_t i; + + while (argv[n] != NULL) { + n++; + } + for (i = 0; i < n; i++) { + if (i > 0) { + (void)fputc(' ', out); + } + if (i + 1 == n) { + char *q = st_sh_quote_ex(argv[i], err); + + if (q == NULL) { + if (err != NULL && *err == NULL) { + *err = st_error_internal("out of memory quoting probe " + "argv"); + } + return -1; + } + (void)fputs(q, out); + free(q); + } else { + (void)fputs(argv[i], out); + } + } + return 0; +} + +/* ---- the cache boilerplate -------------------------------------------- */ + +/* Emitted verbatim (POSIX-sh only). Uses st_sh_emit_str, so no format + * processing — the `%` in `printf '%s'` and `${var#*=}` stays literal. */ +static const char CACHE_FUNCS[] = + "# --- configure-time probe cache (config.cache) ----------------------\n" + "# Emitted by st_probe_emit_cache_functions (todo 12). The probe\n" + "# snippets read results via st_cache_get and record them via\n" + "# st_cache_set. Cache key: ac_cv__.\n" + "\n" + ": \"${CC:=cc}\"\n" + ": \"${CFLAGS:=}\"\n" + ": \"${st_cc_id:=unknown}\"\n" + ": \"${cross_compiling:=}\"\n" + ": \"${config_cache:=./config.cache}\"\n" + ": \"${config_log:=./config.log}\"\n" + ": \"${st_tmpdir:=${TMPDIR:-/tmp}}\"\n" + "\n" + "# st_cache_get KEY: print the cached value (yes/no) and return 0, or\n" + "# return 1 when config.cache is absent or KEY is not recorded. The\n" + "# key is matched with grep -F (literal, no regex) anchored by its\n" + "# trailing '='; keys and values never contain '='.\n" + "st_cache_get() {\n" + " st_cv_line=\n" + " [ -f \"$config_cache\" ] || return 1\n" + " st_cv_line=$(grep -F -- \"$1=\" \"$config_cache\" 2>/dev/null | tail -n 1)\n" + " [ -n \"$st_cv_line\" ] || return 1\n" + " printf '%s\\n' \"${st_cv_line#*=}\"\n" + " return 0\n" + "}\n" + "\n" + "# st_cache_set KEY VALUE: record VALUE (yes/no) under KEY, replacing\n" + "# any earlier line for the same key. Creates config.cache when absent.\n" + "st_cache_set() {\n" + " st_cv_tmp=\"$config_cache.tmp.$$\"\n" + " if [ -f \"$config_cache\" ]; then\n" + " grep -v -F -- \"$1=\" \"$config_cache\" > \"$st_cv_tmp\" 2>/dev/null\n" + " mv -f \"$st_cv_tmp\" \"$config_cache\"\n" + " fi\n" + " printf '%s=%s\\n' \"$1\" \"$2\" >> \"$config_cache\"\n" + " rm -f \"$st_cv_tmp\"\n" + "}\n" + "\n"; + +struct st_error * +st_probe_emit_cache_functions(FILE *out) +{ + if (out == NULL) { + return st_error_usage("st_probe_emit_cache_functions: NULL stream"); + } + if (st_sh_emit_str(out, CACHE_FUNCS) < 0) { + return st_error_io("I/O error emitting cache boilerplate"); + } + return NULL; +} + +/* ---- the per-check snippet -------------------------------------------- */ + +struct st_error * +st_probe_emit_snippet(const char *checkname, const struct st_check_probe *probe, + FILE *out) +{ + struct st_error *err = NULL; + const char *mode_name; + char *qsrc = NULL; + bool is_command; + bool is_run; + + if (out == NULL || checkname == NULL || probe == NULL) { + return st_error_usage("st_probe_emit_snippet: NULL argument"); + } + if (!valid_ident(checkname)) { + return st_error_usage("st_probe_emit_snippet: checkname must be a " + "POSIX shell identifier"); + } + if (probe->mode < ST_PROBE_PREPROCESS || probe->mode > ST_PROBE_COMMAND) { + return st_error_usage("st_probe_emit_snippet: unknown probe mode"); + } + mode_name = st_check_probe_mode_name(probe->mode); + is_command = (probe->mode == ST_PROBE_COMMAND); + is_run = (probe->mode == ST_PROBE_RUN); + + if (is_command) { + if (probe->command == NULL || probe->command[0] == NULL) { + return st_error_usage("st_probe_emit_snippet: COMMAND probe has " + "no command argv"); + } + } else { + if (probe->c_source == NULL) { + return st_error_usage("st_probe_emit_snippet: probe has no C " + "source"); + } + qsrc = st_sh_quote_ex(probe->c_source, &err); + if (err != NULL) { + return err; + } + if (qsrc == NULL) { + return st_error_internal("st_probe_emit_snippet: out of memory " + "quoting C source"); + } + } + + (void)fprintf(out, "# probe: %s check '%s'\n", mode_name, checkname); + (void)fprintf(out, "st_cv_key=ac_cv_%s_$st_cc_id\n", checkname); + + if (is_run) { + /* cross-compile guard FIRST: skip, warn, mark, default "no", and do + * NOT read or write the cache (a cross default must never poison a + * native build's cache entry). */ + (void)fprintf(out, + "if [ \"$cross_compiling\" = \"yes\" ]; then\n" + " st_cross_%s=yes\n" + " have_%s=no\n" + " echo \"warning: skipping runtime check '%s' while " + "cross-compiling\" >&2\n" + "elif st_cv_val=$(st_cache_get \"$st_cv_key\"); then\n" + " have_%s=$st_cv_val\n" + "else\n" + " have_%s=no\n", + checkname, checkname, checkname, checkname, checkname); + } else { + (void)fprintf(out, + "if st_cv_val=$(st_cache_get \"$st_cv_key\"); then\n" + " have_%s=$st_cv_val\n" + "else\n" + " have_%s=no\n", + checkname, checkname); + } + + /* ---- probe body -------------------------------------------------- */ + if (is_command) { + (void)fputs(" if ", out); + if (emit_command_argv(out, probe->command, &err) < 0) { + free(qsrc); + return err; + } + (void)fprintf(out, + " >>\"$config_log\" 2>&1; then\n" + " have_%s=yes\n" + " fi\n", + checkname); + } else { + (void)fprintf(out, + " st_probe_src=\"$st_tmpdir/st_probe_%s.$$.c\"\n" + " printf '%%s' %s > \"$st_probe_src\"\n", + checkname, qsrc); + switch (probe->mode) { + case ST_PROBE_PREPROCESS: + (void)fputs(" if $CC $CFLAGS", out); + if (probe->extra_args != NULL) { + (void)fputc(' ', out); + if (emit_argv_quoted(out, probe->extra_args, &err) < 0) { + free(qsrc); + return err; + } + } + (void)fprintf(out, + " -E \"$st_probe_src\" >>\"$config_log\" 2>&1; then\n" + " have_%s=yes\n" + " fi\n" + " rm -f \"$st_probe_src\"\n", + checkname); + break; + case ST_PROBE_COMPILE: + (void)fputs(" if $CC $CFLAGS", out); + if (probe->extra_args != NULL) { + (void)fputc(' ', out); + if (emit_argv_quoted(out, probe->extra_args, &err) < 0) { + free(qsrc); + return err; + } + } + (void)fprintf(out, + " -c \"$st_probe_src\" -o \"$st_probe_src.o\" " + ">>\"$config_log\" 2>&1; then\n" + " have_%s=yes\n" + " fi\n" + " rm -f \"$st_probe_src\" \"$st_probe_src.o\"\n", + checkname); + break; + case ST_PROBE_LINK: + (void)fputs(" if $CC $CFLAGS", out); + if (probe->extra_args != NULL) { + (void)fputc(' ', out); + if (emit_argv_quoted(out, probe->extra_args, &err) < 0) { + free(qsrc); + return err; + } + } + (void)fprintf(out, + " \"$st_probe_src\" -o \"$st_probe_src.bin\" " + ">>\"$config_log\" 2>&1; then\n" + " have_%s=yes\n" + " fi\n" + " rm -f \"$st_probe_src\" \"$st_probe_src.bin\"\n", + checkname); + break; + case ST_PROBE_RUN: + (void)fputs(" if $CC $CFLAGS", out); + if (probe->extra_args != NULL) { + (void)fputc(' ', out); + if (emit_argv_quoted(out, probe->extra_args, &err) < 0) { + free(qsrc); + return err; + } + } + (void)fprintf(out, + " \"$st_probe_src\" -o \"$st_probe_src.bin\" " + ">>\"$config_log\" 2>&1; then\n" + " if \"$st_probe_src.bin\" >>\"$config_log\" 2>&1; " + "then\n" + " have_%s=yes\n" + " fi\n" + " fi\n" + " rm -f \"$st_probe_src\" \"$st_probe_src.bin\"\n", + checkname); + break; + default: + free(qsrc); + return st_error_usage("st_probe_emit_snippet: unknown probe mode"); + } + } + + /* ---- cache write + close ---------------------------------------- */ + (void)fprintf(out, " st_cache_set \"$st_cv_key\" \"$have_%s\"\n" + "fi\n", checkname); + + free(qsrc); + if (ferror(out)) { + return st_error_io("I/O error emitting probe snippet"); + } + return NULL; +} + +/* ---- a feature's list of checks --------------------------------------- */ + +struct st_error * +st_probe_emit_all(const struct st_probe_entry *entries, size_t count, + FILE *out) +{ + size_t i; + + if (count > 0 && entries == NULL) { + return st_error_usage("st_probe_emit_all: NULL entries"); + } + for (i = 0; i < count; i++) { + struct st_error *err; + + if (i > 0 && fputc('\n', out) == EOF) { + return st_error_io("I/O error emitting probe snippet"); + } + err = st_probe_emit_snippet(entries[i].name, entries[i].probe, out); + if (err != NULL) { + return err; + } + } + return NULL; +} diff --git a/src/detect/probe.h b/src/detect/probe.h new file mode 100644 index 0000000..13387c6 --- /dev/null +++ b/src/detect/probe.h @@ -0,0 +1,163 @@ +/* + * probe.h - configure-time probe snippet generator (todo 12). + * + * MODEL (the thing to internalize before reading anything else): this + * module NEVER executes a probe. It is a pure CODE GENERATOR: given a + * check's probe spec (struct st_check_probe, todo 11), it EMITS a POSIX-sh + * snippet that, when it runs inside the generated ./configure at configure + * time, writes the probe's C source to a temp file, drives the detected + * toolchain ($CC -c / -o / run), captures the exit status, and records + * have_=yes|no. The generator is therefore STATELESS — it takes + * no context object, because all probe context (compiler, flags, + * cross-compile flag, compiler id, cache/log/tmp paths) lives in the + * configure-time shell environment, not in the generator process. + * + * CONFIG.SHELL VARIABLE CONTRACT (todo 16's preamble MUST establish these; + * the boilerplate below emits conservative defaults): + * + * CC the C compiler command (word-split; default "cc") + * CFLAGS extra compiler flags (default empty) + * cross_compiling "yes" when cross-compiling (--host != --build), else + * unset/empty. RUN probes test `[ "$cross_compiling" = + * "yes" ]` and skip execution when set. + * st_cc_id compiler identity string used in cache keys (default + * "unknown"). Must be a shell-safe token free of `=` and + * regex metacharacters (compiler ids are, e.g. + * "gcc"/"clang"); it is interpolated into a cache key + * that st_cache_get matches with grep -F. + * config_cache path to the runtime result cache (default ./config.cache) + * config_log path probe stdout/stderr is appended to (default ./config.log) + * st_tmpdir directory for temp probe files (default ${TMPDIR:-/tmp}) + * + * CACHE KEY SCHEME + * ---------------- + * One config.cache line per check per compiler, value `yes` or `no`: + * + * ac_cv__=yes|no + * + * `` is the unique name passed to st_probe_emit_snippet (see + * below); `` is the configure-time $st_cc_id. This is the + * ac_cv_func_strdup pattern generalized: the key is derived from the check + * AND the compiler so that re-running configure under a different compiler + * re-probes instead of trusting a foreign cache entry. + * + * EMITTED SNIPPET SHAPE (per check) + * --------------------------------- + * # probe: check '' + * st_cv_key=ac_cv__$st_cc_id + * if st_cv_val=$(st_cache_get "$st_cv_key"); then + * have_=$st_cv_val + * else + * have_=no + * =yes on success> + * st_cache_set "$st_cv_key" "$have_" + * fi + * + * RUN probes wrap that in a cross-compile guard FIRST: + * if [ "$cross_compiling" = "yes" ]; then + * st_cross_=yes + * have_=no + * echo "warning: skipping runtime check '' while + * cross-compiling" >&2 + * elif st_cv_val=$(st_cache_get "$st_cv_key"); then + * have_=$st_cv_val + * else + * ...compile + run... + * fi + * The cross-skipped result is NOT written to config.cache (a cross default + * must never poison a native build's cache entry). + * + * Mode bodies (all probe stdout+stderr appended to "$config_log" via + * >>"$config_log" 2>&1; temp files removed with rm -f; the .c source is + * written with `printf '%s' > "$st_probe_src"` — + * single-quote wrapping, NOT a here-document, so a C source containing + * arbitrary bytes incl. newlines and the delimiter-like text stays inert): + * COMPILE $CC $CFLAGS -c "$src" -o "$src.o" + * LINK $CC $CFLAGS "$src" -o "$src.bin" + * RUN compile+link, then "$src.bin" + * COMMAND : every element except the LAST is a hardcoded + * literal (program name "command"/"pkg-config" + fixed flags) + * and is emitted verbatim; the LAST element (the user-supplied + * target) is single-quoted. e.g. `command -v 'pkg-config'`, + * `pkg-config --cflags --libs 'openssl'`. (A future kind + * supplying a user-controlled non-final element must quote it.) + * PREPROCESS $CC $CFLAGS -E "$src" (reserved; unused by v1) + * + * CHECKNAME CONTRACT + * ------------------ + * `checkname` MUST be a valid POSIX shell identifier ([A-Za-z_][A-Za-z0-9_]*). + * It is interpolated into the variable names have_ / + * st_cross_ / ac_cv__$st_cc_id and a temp filename, + * none of which can be shell-quoted. st_probe_emit_snippet validates this + * and returns ST_ERR_USAGE on violation (mirroring sh_emit.h's documented + * trust boundary for `var`, but enforced rather than trusted because the + * value reaches a variable-NAME position). The caller chooses a unique + * checkname per check: for a single-check feature that is the feature name + * (so have_ is set directly); for a multi-check feature the caller + * derives distinct names and todo 14/16 ANDs them into have_. + * + * VERSION CONSTRAINT + * ------------------ + * probe->version_constraint is NOT consumed by the emitted snippet. For + * pkg_config the constraint operator was already folded into the command + * argv by checks.c (e.g. --atleast-version=1.1); for library the constraint + * is recorded only and todo 16 enforces it (via pkg-config when available). + * This field is simply ignored here. + * + * ERRORS + * ------ + * All functions return NULL on success. Failures return an owned st_error: + * ST_ERR_USAGE for NULL arguments, an invalid checkname, an out-of-range + * probe mode, a COMMAND probe with no command argv, or a non-COMMAND probe + * with no C source; ST_ERR_INTERNAL when st_sh_quote fails (NUL byte / OOM, + * propagated) or quoting allocation fails; ST_ERR_IO when writing to the + * stream fails (detected via ferror after emission). + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef ST_DETECT_PROBE_H +#define ST_DETECT_PROBE_H + +#include +#include + +#include "detect/checks.h" /* struct st_check_probe, enum st_check_probe_mode */ + +struct st_error; + +/* Emit the config.cache boilerplate: the st_cache_get / st_cache_set shell + * functions plus the conservative defaults for the contract variables + * (CC/CFLAGS/cross_compiling/st_cc_id/config_cache/config_log/st_tmpdir). + * This is emitted ONCE per configure script, before any probe snippet. The + * two functions are: + * st_cache_get KEY -> echoes the cached value (yes/no) and returns 0, + * or returns 1 when config.cache is absent or KEY + * is not recorded. + * st_cache_set KEY VALUE -> records VALUE (yes/no) under KEY, replacing + * any prior line for the same key; creates + * config.cache when absent. + * POSIX-only (no `local`, no arrays, no `==`). */ +struct st_error *st_probe_emit_cache_functions(FILE *out); + +/* Emit the POSIX-sh snippet that probes ONE check at configure time. + * Appends to `out`; sets have_=yes|no (always, on every path). + * Returns NULL on success. */ +struct st_error *st_probe_emit_snippet(const char *checkname, + const struct st_check_probe *probe, + FILE *out); + +/* One entry of a feature's check list: a unique check name paired with its + * probe spec. `name` is the have_ variable + cache-key base. */ +struct st_probe_entry { + const char *name; /* unique POSIX identifier */ + const struct st_check_probe *probe; /* borrowed; not freed here */ +}; + +/* Emit a feature's list of checks (each via st_probe_emit_snippet). A blank + * line separates checks. Returns NULL on success, or the first error. */ +struct st_error *st_probe_emit_all(const struct st_probe_entry *entries, + size_t count, FILE *out); + +#endif /* ST_DETECT_PROBE_H */ diff --git a/tests/unit/test_probe.c b/tests/unit/test_probe.c new file mode 100644 index 0000000..fd46b94 --- /dev/null +++ b/tests/unit/test_probe.c @@ -0,0 +1,758 @@ +/* LINK: ../../src/detect/probe.c ../../src/detect/checks.c ../../src/detect/check_registry.c ../../src/kdl/schema.c ../../src/kdl/parser.c ../../src/kdl/lexer.c ../../src/kdl/value.c ../../src/gen/sh_emit.c ../../src/error.c ../../src/span.c */ + +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L /* mkdtemp, system/WEXITSTATUS */ +#endif + +/* + * tests/unit/test_probe.c + * + * Unit tests for the configure-time probe snippet generator (todo 12): + * src/detect/probe.c. THE MODEL: probes do NOT run here, at stupidtools + * generation time — this module only EMITS POSIX-sh that runs at configure + * time inside the generated ./configure. These tests prove that emitted + * shell is syntactically valid (sh/bash/zsh -n), genuinely executes (real + * `cc` against real headers), short-circuits on config.cache, cross-compile + * guards RUN probes, and keeps hostile probe targets inert via st_sh_quote. + * + * The magic LINK comment on line 1 is REQUIRED by tests/run.sh (extra .c + * sources, relative to tests/unit/). probe.c needs sh_emit.c (st_sh_quote) + * + error.c + span.c; checks.c/check_registry.c/parser.c/lexer.c/value.c/ + * schema.c are linked so canonical probes are built END-TO-END from DSL + * source (st_check_probe_build), exercising the real checks.c -> probe.c + * contract rather than hand-built structs alone. + */ +#include "munit.h" + +#include "detect/check_registry.h" +#include "detect/checks.h" +#include "detect/probe.h" +#include "error.h" +#include "kdl/ast.h" + +#include +#include +#include +#include +#include +#include + +/* ---- per-test temp dir ------------------------------------------------- */ + +static char temp_dir[128]; + +static void * +setup(const MunitParameter params[], void *user_data) +{ + (void)params; + (void)user_data; + int n = snprintf(temp_dir, sizeof temp_dir, "/tmp/st_probe_XXXXXX"); + + if (n < 0) { + return NULL; + } + if (mkdtemp(temp_dir) == NULL) { + return NULL; + } + return (void *)1; +} + +static void +teardown(void *fixture) +{ + char cmd[160]; + int n; + + if (fixture == NULL || temp_dir[0] == '\0') { + return; + } + n = snprintf(cmd, sizeof cmd, "rm -rf -- '%s'", temp_dir); + if (n < 0 || (size_t)n >= sizeof cmd) { + return; + } + (void)system(cmd); /* best-effort; mkdtemp names are [A-Za-z0-9_]* */ + temp_dir[0] = '\0'; +} + +/* Join temp_dir/`name` into `buf`; returns the snprintf result so callers + * can assert it without ever stringifying a %-carrying format (the munit + * %s-stringification trap, see .omo/notepads/stupidtools/learnings.md). */ +static int +mkpath(char *buf, size_t sz, const char *name) +{ + return snprintf(buf, sz, "%s/%s", temp_dir, name); +} + +/* ---- probe construction ------------------------------------------------- */ + +/* Parse `src` as one check node, resolve its kind, build its probe spec + * into `*out` (fully heap-owned). Asserts each step succeeds and frees the + * document before returning. The caller frees via st_check_probe_free. */ +static enum st_check_kind +build_from(const char *src, struct st_check_probe *out) +{ + struct st_error *err = NULL; + struct st_kdl_document *doc = NULL; + struct st_kdl_node *node; + enum st_check_kind kind; + + doc = st_kdl_parse(src, "t.kdl", &err); + munit_assert_null(err); + munit_assert_not_null(doc); + node = doc->nodes; + kind = st_check_kind_from_node(node, &err); + munit_assert_null(err); + munit_assert_int(kind, !=, ST_CHECK_KIND_COUNT); + munit_assert_null(st_check_probe_build(kind, node, out)); + st_kdl_document_free(doc); + return kind; +} + +/* ---- emit helpers ------------------------------------------------------ */ + +/* Emit one check's snippet into a temp file, return its bytes (NUL- + * terminated). Returns NULL on any failure. The buffer is static (munit + * forks per test, so it is never shared across tests). */ +static const char * +emit_snippet_text(const char *checkname, const struct st_check_probe *p) +{ + static char buf[8192]; + char path[600]; + FILE *f; + size_t n; + struct st_error *err; + int m; + + m = mkpath(path, sizeof path, "snippet.sh"); + if (m < 0) { + return NULL; + } + f = fopen(path, "w"); + if (f == NULL) { + return NULL; + } + err = st_probe_emit_snippet(checkname, p, f); + fclose(f); + if (err != NULL) { + st_error_free(err); + return NULL; + } + f = fopen(path, "r"); + if (f == NULL) { + return NULL; + } + n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + buf[n] = '\0'; + return buf; +} + +/* The configure-time environment the script's stub preamble plants. */ +struct env { + const char *cc; /* CC value; NULL -> "cc" */ + const char *cc_id; /* st_cc_id; NULL -> "gcc" */ + const char *cross; /* "yes" -> cross_compiling=yes; else unset */ +}; + +/* Write a runnable script: shebang, preamble assignments, the cache + * boilerplate, ONE snippet, and a dump of have_ + st_cross_ + * into have.out / cross.out under temp_dir. Returns 0 on success. */ +static int +write_script(const char *checkname, const struct st_check_probe *p, + const struct env *env) +{ + char path[600]; + FILE *f; + struct st_error *err; + int m; + + m = mkpath(path, sizeof path, "run.sh"); + if (m < 0) { + return -1; + } + f = fopen(path, "w"); + if (f == NULL) { + return -1; + } + (void)fprintf(f, "#!/bin/sh\n"); + (void)fprintf(f, "CC='%s'\n", + env != NULL && env->cc != NULL ? env->cc : "cc"); + (void)fprintf(f, "st_cc_id='%s'\n", + env != NULL && env->cc_id != NULL ? env->cc_id : "gcc"); + if (env != NULL && env->cross != NULL && strcmp(env->cross, "yes") == 0) { + (void)fprintf(f, "cross_compiling=yes\n"); + } + (void)fprintf(f, "config_cache='%s/config.cache'\n", temp_dir); + (void)fprintf(f, "config_log='%s/config.log'\n", temp_dir); + (void)fprintf(f, "st_tmpdir='%s'\n", temp_dir); + err = st_probe_emit_cache_functions(f); + if (err != NULL) { + st_error_free(err); + fclose(f); + return -1; + } + err = st_probe_emit_snippet(checkname, p, f); + if (err != NULL) { + st_error_free(err); + fclose(f); + return -1; + } + (void)fprintf(f, "printf '%%s\\n' \"$have_%s\" > '%s/have.out'\n", + checkname, temp_dir); + (void)fprintf(f, "printf '%%s\\n' \"${st_cross_%s:-}\" > '%s/cross.out'\n", + checkname, temp_dir); + fclose(f); + return 0; +} + +/* Run "sh " via system(); return the shell's exit status or -1. */ +static int +run_sh(const char *path) +{ + char cmd[700]; + int rc; + int n = snprintf(cmd, sizeof cmd, "sh '%s'", path); + + if (n < 0 || (size_t)n >= sizeof cmd) { + return -1; + } + rc = system(cmd); + if (rc == -1) { + return -1; + } + return WEXITSTATUS(rc); +} + +/* As run_sh, but capture the script's stderr into `err_path`. */ +static int +run_sh_err(const char *path, const char *err_path) +{ + char cmd[1400]; + int rc; + int n = snprintf(cmd, sizeof cmd, "sh '%s' 2>'%s'", path, err_path); + + if (n < 0 || (size_t)n >= sizeof cmd) { + return -1; + } + rc = system(cmd); + if (rc == -1) { + return -1; + } + return WEXITSTATUS(rc); +} + +/* Read a tiny result file under temp_dir, strip the trailing newline. + * Returns a static buffer (empty string when the file is missing). */ +static const char * +read_result(const char *file) +{ + static char buf[256]; + char path[600]; + FILE *f; + size_t n; + int m; + + m = mkpath(path, sizeof path, file); + if (m < 0) { + return ""; + } + f = fopen(path, "r"); + if (f == NULL) { + return ""; + } + n = fread(buf, 1, sizeof buf - 1, f); + fclose(f); + buf[n] = '\0'; + buf[strcspn(buf, "\n")] = '\0'; + return buf; +} + +/* " -n ": return the shell's exit status or -1. */ +static int +syntax_check(const char *shell, const char *path) +{ + char cmd[700]; + int rc; + int n = snprintf(cmd, sizeof cmd, "%s -n '%s'", shell, path); + + if (n < 0 || (size_t)n >= sizeof cmd) { + return -1; + } + rc = system(cmd); + if (rc == -1) { + return -1; + } + return WEXITSTATUS(rc); +} + +/* ---- (a) snippet content ---------------------------------------------- */ + +static MunitResult +test_header_snippet_content(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + const char *s; + + (void)build_from("header \"unistd.h\"", &p); + munit_assert_int(p.mode, ==, ST_PROBE_COMPILE); + + s = emit_snippet_text("unistd_h", &p); + munit_assert_not_null(s); + munit_assert_not_null(strstr(s, "$CC")); + munit_assert_not_null(strstr(s, "-c")); + munit_assert_not_null(strstr(s, "have_unistd_h")); + munit_assert_not_null(strstr(s, "have_unistd_h=yes")); + munit_assert_not_null(strstr(s, "have_unistd_h=no")); + munit_assert_not_null(strstr(s, "#include ")); + munit_assert_not_null(strstr(s, "ac_cv_unistd_h_$st_cc_id")); + munit_assert_not_null(strstr(s, "st_cache_get")); + munit_assert_not_null(strstr(s, "st_cache_set")); + + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* ---- (b) real execution: yes and no ----------------------------------- */ + +static MunitResult +test_header_runs_yes_no(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe yes = { 0 }; + struct st_check_probe no = { 0 }; + char run[600]; + int m; + + (void)build_from("header \"unistd.h\"", &yes); + munit_assert_int(write_script("unistd_h", &yes, NULL), ==, 0); + m = mkpath(run, sizeof run, "run.sh"); + munit_assert_int(m, >, 0); + munit_assert_int(run_sh(run), ==, 0); + munit_assert_string_equal(read_result("have.out"), "yes"); + + (void)build_from("header \"nope_missing_xyz.h\"", &no); + munit_assert_int(write_script("nope", &no, NULL), ==, 0); + munit_assert_int(run_sh(run), ==, 0); + munit_assert_string_equal(read_result("have.out"), "no"); + + st_check_probe_free(&yes); + st_check_probe_free(&no); + return MUNIT_OK; +} + +/* ---- (c) RUN probe: cross-compile guard ------------------------------- */ + +static MunitResult +test_run_cross_guard(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + struct env cross = { "/bin/false", "gcc", "yes" }; + const char *s; + char run[600]; + char errp[600]; + char logpath[600]; + int m; + int rc; + + (void)build_from("sizeof \"long\"", &p); + munit_assert_int(p.mode, ==, ST_PROBE_RUN); + + /* static: the emitted snippet carries a cross_compiling guard */ + s = emit_snippet_text("sz", &p); + munit_assert_not_null(s); + munit_assert_not_null(strstr(s, "cross_compiling")); + + /* dynamic: cross_compiling=yes + a failing CC -> skip, no compile */ + munit_assert_int(write_script("sz", &p, &cross), ==, 0); + m = mkpath(run, sizeof run, "run.sh"); + munit_assert_int(m, >, 0); + m = mkpath(errp, sizeof errp, "cross.err"); + munit_assert_int(m, >, 0); + rc = run_sh_err(run, errp); + munit_assert_int(rc, ==, 0); + munit_assert_string_equal(read_result("have.out"), "no"); + munit_assert_string_equal(read_result("cross.out"), "yes"); + /* the warning landed on stderr */ + { + const char *e = read_result("cross.err"); + + munit_assert_true(strstr(e, "cross-compiling") != NULL); + } + /* no compile was attempted: config.log was never created */ + m = mkpath(logpath, sizeof logpath, "config.log"); + munit_assert_int(m, >, 0); + munit_assert_int(access(logpath, F_OK), ==, -1); + + /* dynamic: cross_compiling unset -> actually compiles and runs */ + munit_assert_int(write_script("sz", &p, NULL), ==, 0); + munit_assert_int(run_sh(run), ==, 0); + munit_assert_string_equal(read_result("have.out"), "yes"); + munit_assert_string_equal(read_result("cross.out"), ""); + /* the run output (sizeof value) landed in config.log */ + munit_assert_int(access(logpath, F_OK), ==, 0); + + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* ---- (d) COMMAND probe: argv quoting ---------------------------------- */ + +static MunitResult +test_command_probe_quoting(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe prog = { 0 }; + struct st_check_probe pk = { 0 }; + const char *s; + + (void)build_from("program \"pkg-config\"", &prog); + s = emit_snippet_text("pgc", &prog); + munit_assert_not_null(s); + munit_assert_not_null(strstr(s, "command -v 'pkg-config'")); + + (void)build_from("pkg_config \"openssl\"", &pk); + s = emit_snippet_text("ossl", &pk); + munit_assert_not_null(s); + munit_assert_not_null(strstr(s, "pkg-config --cflags --libs 'openssl'")); + + st_check_probe_free(&prog); + st_check_probe_free(&pk); + return MUNIT_OK; +} + +/* ---- (e) config.cache short-circuit ----------------------------------- */ + +static MunitResult +test_cache_short_circuit(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + struct env bad = { "/bin/false", "gcc", NULL }; + char cache[600]; + char run[600]; + char logpath[600]; + FILE *f; + int m; + int wr; + + /* pre-seed the cache with a "yes" for a header that does NOT exist, + * then point CC at /bin/false: only a cache hit can yield yes. */ + (void)build_from("header \"nope_missing_xyz.h\"", &p); + m = mkpath(cache, sizeof cache, "config.cache"); + munit_assert_int(m, >, 0); + f = fopen(cache, "w"); + munit_assert_not_null(f); + wr = fputs("ac_cv_cached_gcc=yes\n", f); + munit_assert_int(wr, !=, EOF); + fclose(f); + + munit_assert_int(write_script("cached", &p, &bad), ==, 0); + m = mkpath(run, sizeof run, "run.sh"); + munit_assert_int(m, >, 0); + munit_assert_int(run_sh(run), ==, 0); + munit_assert_string_equal(read_result("have.out"), "yes"); + + /* no probe was attempted (compiler never ran) */ + m = mkpath(logpath, sizeof logpath, "config.log"); + munit_assert_int(m, >, 0); + munit_assert_int(access(logpath, F_OK), ==, -1); + + /* the cached line was consumed, not overwritten with "no" */ + { + const char *c = read_result("config.cache"); + + munit_assert_not_null(strstr(c, "ac_cv_cached_gcc=yes")); + } + + st_check_probe_free(&p); + return MUNIT_OK; +} + +static MunitResult +test_cache_write(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + char run[600]; + const char *c; + int m; + + (void)build_from("header \"unistd.h\"", &p); + munit_assert_int(write_script("writecc", &p, NULL), ==, 0); + m = mkpath(run, sizeof run, "run.sh"); + munit_assert_int(m, >, 0); + munit_assert_int(run_sh(run), ==, 0); + munit_assert_string_equal(read_result("have.out"), "yes"); + + c = read_result("config.cache"); + munit_assert_not_null(strstr(c, "ac_cv_writecc_gcc=yes")); + + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* ---- (f) syntax: sh -n / bash -n / zsh -n + banned-construct sweep ---- */ + +static MunitResult +test_syntax_and_banned(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + static const char *const banned[] = { + "[[ ", "]]", "local ", "==", "<<<", "&>", "set -e", + }; + struct st_check_probe hdr = { 0 }; + struct st_check_probe fn = { 0 }; + struct st_check_probe sz = { 0 }; + struct st_check_probe prog = { 0 }; + char path[600]; + char *bytes; + FILE *f; + long n; + size_t i; + int m; + + (void)build_from("header \"unistd.h\"", &hdr); + (void)build_from("function \"strdup\"", &fn); + (void)build_from("sizeof \"long\"", &sz); + (void)build_from("program \"pkg-config\"", &prog); + + m = mkpath(path, sizeof path, "all.sh"); + munit_assert_int(m, >, 0); + f = fopen(path, "w"); + munit_assert_not_null(f); + (void)fprintf(f, "#!/bin/sh\n"); + munit_assert_null(st_probe_emit_cache_functions(f)); + munit_assert_null(st_probe_emit_snippet("hdr", &hdr, f)); + munit_assert_null(st_probe_emit_snippet("fn", &fn, f)); + munit_assert_null(st_probe_emit_snippet("sz", &sz, f)); + munit_assert_null(st_probe_emit_snippet("prog", &prog, f)); + fclose(f); + + munit_assert_int(syntax_check("sh", path), ==, 0); + munit_assert_int(syntax_check("bash", path), ==, 0); + munit_assert_int(syntax_check("zsh", path), ==, 0); + + /* banned-construct sweep on the real emitted bytes */ + f = fopen(path, "rb"); + munit_assert_not_null(f); + munit_assert_int(fseek(f, 0, SEEK_END), ==, 0); + n = ftell(f); + munit_assert_int(n, >, 0); + munit_assert_int(fseek(f, 0, SEEK_SET), ==, 0); + bytes = munit_malloc((size_t)n + 1); + munit_assert_size(fread(bytes, 1, (size_t)n, f), ==, (size_t)n); + fclose(f); + bytes[n] = '\0'; + for (i = 0; i < sizeof banned / sizeof banned[0]; i++) { + munit_assert_null(strstr(bytes, banned[i])); + } + free(bytes); + + st_check_probe_free(&hdr); + st_check_probe_free(&fn); + st_check_probe_free(&sz); + st_check_probe_free(&prog); + return MUNIT_OK; +} + +/* ---- (g) injection: hostile probe target stays inert ------------------ */ + +static MunitResult +test_injection_inert(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + char marker[600]; + char csrc[700]; + char run[600]; + int m; + int rc; + struct st_check_probe p; + + /* A C source that, if the shell ever interpreted it (unquoted), would + * remove `marker`'s directory. st_sh_quote makes it a literal; the + * compile just fails and the shell never executes the embedded rm. */ + m = snprintf(marker, sizeof marker, "%s/victim.txt", temp_dir); + munit_assert_int(m, >, 0); + m = snprintf(csrc, sizeof csrc, + "#include \n; rm -rf %s; \"\n", marker); + munit_assert_int(m, >, 0); + p = (struct st_check_probe){ + .mode = ST_PROBE_COMPILE, + .c_source = csrc, + }; + munit_assert_int(write_script("hostile", &p, NULL), ==, 0); + m = mkpath(run, sizeof run, "run.sh"); + munit_assert_int(m, >, 0); + rc = run_sh(run); + munit_assert_int(rc, ==, 0); + munit_assert_string_equal(read_result("have.out"), "no"); + munit_assert_int(access(marker, F_OK), ==, -1); + return MUNIT_OK; +} + +/* ---- emit_all: a feature's list of checks ----------------------------- */ + +static MunitResult +test_emit_all(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe a = { .mode = ST_PROBE_COMPILE, + .c_source = "#include \n" }; + struct st_check_probe b = { .mode = ST_PROBE_COMPILE, + .c_source = "#include \n" }; + struct st_probe_entry entries[2]; + char path[600]; + FILE *f; + struct st_error *err; + char *bytes; + long n; + int m; + + entries[0].name = "alpha"; + entries[0].probe = &a; + entries[1].name = "beta"; + entries[1].probe = &b; + + m = mkpath(path, sizeof path, "all2.sh"); + munit_assert_int(m, >, 0); + f = fopen(path, "w"); + munit_assert_not_null(f); + munit_assert_null(st_probe_emit_cache_functions(f)); + err = st_probe_emit_all(entries, 2, f); + munit_assert_null(err); + fclose(f); + + f = fopen(path, "rb"); + munit_assert_not_null(f); + munit_assert_int(fseek(f, 0, SEEK_END), ==, 0); + n = ftell(f); + munit_assert_int(fseek(f, 0, SEEK_SET), ==, 0); + bytes = munit_malloc((size_t)n + 1); + munit_assert_size(fread(bytes, 1, (size_t)n, f), ==, (size_t)n); + fclose(f); + bytes[n] = '\0'; + munit_assert_not_null(strstr(bytes, "have_alpha")); + munit_assert_not_null(strstr(bytes, "have_beta")); + munit_assert_not_null(strstr(bytes, "ac_cv_alpha_$st_cc_id")); + munit_assert_not_null(strstr(bytes, "ac_cv_beta_$st_cc_id")); + free(bytes); + + /* empty list is a clean no-op */ + f = tmpfile(); + munit_assert_not_null(f); + err = st_probe_emit_all(NULL, 0, f); + munit_assert_null(err); + fclose(f); + + return MUNIT_OK; +} + +/* ---- error handling ---------------------------------------------------- */ + +static MunitResult +test_errors(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe good = { .mode = ST_PROBE_COMPILE, + .c_source = "#include \n" }; + struct st_check_probe nocmd = { .mode = ST_PROBE_COMMAND, + .command = NULL }; + struct st_check_probe nosrc = { .mode = ST_PROBE_COMPILE, + .c_source = NULL }; + struct st_check_probe badmode = { .mode = (enum st_check_probe_mode)999, + .c_source = "x" }; + FILE *f = tmpfile(); + struct st_error *err; + + munit_assert_not_null(f); + + err = st_probe_emit_snippet(NULL, &good, f); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE); + st_error_free(err); + + err = st_probe_emit_snippet("bad name", &good, f); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE); + st_error_free(err); + + err = st_probe_emit_snippet("ok", NULL, f); + munit_assert_not_null(err); + st_error_free(err); + + err = st_probe_emit_snippet("ok", &good, NULL); + munit_assert_not_null(err); + st_error_free(err); + + err = st_probe_emit_snippet("ok", &badmode, f); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE); + st_error_free(err); + + err = st_probe_emit_snippet("ok", &nocmd, f); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE); + st_error_free(err); + + err = st_probe_emit_snippet("ok", &nosrc, f); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE); + st_error_free(err); + + err = st_probe_emit_cache_functions(NULL); + munit_assert_not_null(err); + st_error_free(err); + + /* a valid emit returns no error */ + err = st_probe_emit_snippet("ok", &good, f); + munit_assert_null(err); + + fclose(f); + return MUNIT_OK; +} + +static MunitTest tests[] = { + { "/probe/header-snippet-content", test_header_snippet_content, setup, + teardown, MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/header-runs-yes-no", test_header_runs_yes_no, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/run-cross-guard", test_run_cross_guard, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/command-quoting", test_command_probe_quoting, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/cache-short-circuit", test_cache_short_circuit, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/cache-write", test_cache_write, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/syntax-banned", test_syntax_and_banned, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/injection-inert", test_injection_inert, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/emit-all", test_emit_all, setup, teardown, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/probe/errors", test_errors, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, +}; + +static const MunitSuite suite = { + "/probe", tests, NULL, 1, MUNIT_SUITE_OPTION_NONE, +}; + +int +main(int argc, char *argv[MUNIT_ARRAY_PARAM(argc + 1)]) +{ + return munit_suite_main(&suite, NULL, argc, argv); +}