feat(detect): add probe code generator with result cache

This commit is contained in:
2026-08-28 22:56:20 -04:00
parent 7a8abea6d9
commit ac0e0fd39a
3 changed files with 1304 additions and 0 deletions
+383
View File
@@ -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_sh_quote(c_source)> >
* "$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_<name>,
* ac_cv_<name>_$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 <stdbool.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
/* ---- 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_<checkname>_<compiler-id>.\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;
}
+163
View File
@@ -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_<checkname>=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_<checkname>_<compiler-id>=yes|no
*
* `<checkname>` is the unique name passed to st_probe_emit_snippet (see
* below); `<compiler-id>` 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: <mode> check '<checkname>'
* st_cv_key=ac_cv_<checkname>_$st_cc_id
* if st_cv_val=$(st_cache_get "$st_cv_key"); then
* have_<checkname>=$st_cv_val
* else
* have_<checkname>=no
* <mode-specific probe body, sets have_<checkname>=yes on success>
* st_cache_set "$st_cv_key" "$have_<checkname>"
* fi
*
* RUN probes wrap that in a cross-compile guard FIRST:
* if [ "$cross_compiling" = "yes" ]; then
* st_cross_<checkname>=yes
* have_<checkname>=no
* echo "warning: skipping runtime check '<checkname>' while
* cross-compiling" >&2
* elif st_cv_val=$(st_cache_get "$st_cv_key"); then
* have_<checkname>=$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_sh_quote(c_source)> > "$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 <extra> -c "$src" -o "$src.o"
* LINK $CC $CFLAGS <extra> "$src" -o "$src.bin"
* RUN compile+link, then "$src.bin"
* COMMAND <command argv>: 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 <extra> -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_<checkname> /
* st_cross_<checkname> / ac_cv_<checkname>_$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_<feature> is set directly); for a multi-check feature the caller
* derives distinct names and todo 14/16 ANDs them into have_<feature>.
*
* 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 <stddef.h>
#include <stdio.h>
#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_<checkname>=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_<name> 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 */