From 7a8abea6d9275aca08f7d2b8eb932ae74eb84091 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Fri, 28 Aug 2026 22:30:53 -0400 Subject: [PATCH] feat(detect): implement check registry probe specs --- src/detect/checks.c | 769 +++++++++++++++++++++++++++++++++++++++ src/detect/checks.h | 131 +++++++ tests/unit/test_checks.c | 482 ++++++++++++++++++++++++ 3 files changed, 1382 insertions(+) create mode 100644 src/detect/checks.c create mode 100644 src/detect/checks.h create mode 100644 tests/unit/test_checks.c diff --git a/src/detect/checks.c b/src/detect/checks.c new file mode 100644 index 0000000..826fb16 --- /dev/null +++ b/src/detect/checks.c @@ -0,0 +1,769 @@ +/* + * checks.c - probe-source specs for the 8 check kinds (todo 11). + * + * Builds a `struct st_check_probe` for a schema-validated CHECK node, per + * the per-kind table documented in detect/checks.h. This module builds + * SPECS ONLY: it does not execute probes (todo 12) and does not emit shell + * text (todo 16). See the header for the struct contract and ownership. + * + * DESIGN NOTES (the decisions that are NOT obvious from the header): + * + * - FUNCTION probes carry `-fno-builtin` in extra_args. GCC treats + * ~1000 names (strdup, printf, malloc, ...) as builtins and, with + * `-Wbuiltin-declaration-mismatch` (on by default), warns on ANY + * declaration whose signature differs from the builtin's — and there + * is no single generic signature compatible with every builtin. A + * generic `extern void NAME(void); ... NAME();` is therefore wrapped + * in `-fno-builtin` so the link probe resolves the REAL library + * symbol (also preventing builtin inlining from giving a false + * positive). The function is declared `void (void)` and CALLED; the C + * ABI does not name-mangle, so linking resolves the symbol NAME + * regardless of its true signature — the probe only checks symbol + * presence and never runs the function. + * + * - LIBRARY probes link a trivial `int main(void){return 0;}` against + * `-l`. The DSL carries only the library name (no function, as + * AC_CHECK_LIB requires), so "present and linkable" is the honest + * v1 meaning: the link fails if `-l` cannot be found or linked. + * + * - VERSION constraints (`library`/`pkg_config` carry an optional + * `version ""` pair) are PARSED here (`` ∈ + * >= <= = > <; `` dotted-numeric) and the raw string is + * recorded in probe->version_constraint. v1 enforcement: + * * library: the probe is the plain `-l` link probe UNCHANGED; the + * constraint is recorded for todo 16 to enforce via pkg-config + * (or a library symbol check) when available. + * * pkg_config: the op maps to a pkg-config version flag — + * `>=` → `--atleast-version=`, `<=` → `--max-version=`, + * `=` → `--exact-version=` — added before `--cflags --libs`. + * Strict `>` and `<` have no pkg-config flag: they are recorded + * in version_constraint only (todo 16 may reject or handle them). + * + * - HEADER names are embedded as `#include ` when the name is + * "plain" (only [A-Za-z0-9_./+-]); otherwise as `#include "..."` with + * `"` and `\` C-escaped, so a hostile name (`"; rm -rf /`) stays a + * literal string and cannot break out of the directive. Any control + * byte (newline/NUL/...) in a header/function/type name is rejected. + * + * - TYPE and SIZEOF names are embedded verbatim in `sizeof(...)`; only + * control bytes are rejected (a `unsigned long`-style multi-token + * type is legitimate, and a bogus type fails the compile probe + * naturally). v1 does NOT add an AC_INCLUDES_DEFAULT-style header + * list, so `type "size_t"` tests only types visible without includes. + * + * ERRORS AND SPANS + * ---------------- + * Failures are owned ST_ERR_KDL_SCHEMA errors with the span heap-allocated + * IN THE SAME BLOCK as the error (the parser.c pattern), at the offending + * token. See checks.h. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "detect/checks.h" + +#include "detect/check_registry.h" +#include "error.h" +#include "kdl/ast.h" +#include "kdl/value.h" + +#include +#include +#include +#include +#include +#include + +/* ---- owned errors ----------------------------------------------------- */ + +/* Build an owned ST_ERR_KDL_SCHEMA error whose span lives in the same + * allocation as the error struct (aligned right after it). Returns NULL + * only on allocation failure. */ +static struct st_error * +err_at_owned(struct st_span sp, const char *msg) +{ + const size_t align = _Alignof(struct st_span); + const size_t esize = + (sizeof(struct st_error) + align - 1) & ~(align - 1); + struct st_error *e; + struct st_span *spc; + size_t mlen; + + if (msg == NULL) { + msg = ""; + } + e = malloc(esize + sizeof(struct st_span)); + if (e == NULL) { + return NULL; + } + mlen = strlen(msg); + e->message = malloc(mlen + 1); + if (e->message == NULL) { + free(e); + return NULL; + } + memcpy(e->message, msg, mlen + 1); + e->category = ST_ERR_KDL_SCHEMA; + spc = (struct st_span *)((unsigned char *)e + esize); + *spc = sp; + e->span = spc; + return e; +} + +/* Re-own a value-model (ST_ERR_KDL_PARSE) error as a schema error: copy + * the message and the span VALUE into a fresh block. The caller frees the + * original error. */ +static struct st_error * +schema_err_from(const struct st_error *e) +{ + struct st_span sp; + + if (e->span != NULL) { + sp = *e->span; + } else { + sp = (struct st_span){ NULL, 0, 0 }; + } + return err_at_owned(sp, e->message != NULL ? e->message : ""); +} + +/* ---- small allocators -------------------------------------------------- */ + +/* Duplicate a string without relying on POSIX strdup (which strict C23 + * hides behind _POSIX_C_SOURCE). Returns NULL on OOM. */ +static char * +xstrdup(const char *s) +{ + size_t n = strlen(s); + char *p = malloc(n + 1); + + if (p != NULL) { + memcpy(p, s, n + 1); + } + return p; +} + +/* vsnprintf-based string builder. Returns a malloc'd NUL-terminated string + * or NULL on OOM / formatting failure. */ +static char * +strbuild(const char *fmt, ...) +{ + va_list ap; + va_list ap2; + int n; + char *out; + + va_start(ap, fmt); + va_copy(ap2, ap); + n = vsnprintf(NULL, 0, fmt, ap); + va_end(ap); + if (n < 0) { + va_end(ap2); + return NULL; + } + out = malloc((size_t)n + 1); + if (out == NULL) { + va_end(ap2); + return NULL; + } + (void)vsnprintf(out, (size_t)n + 1, fmt, ap2); + va_end(ap2); + return out; +} + +/* Free a NULL-terminated argv of heap strings (each element freed, then + * the array). NULL is a safe no-op. */ +static void +argv_free(char **av) +{ + size_t i; + + if (av == NULL) { + return; + } + for (i = 0; av[i] != NULL; i++) { + free(av[i]); + } + free(av); +} + +/* ---- argument extraction ---------------------------------------------- */ + +/* Interpret `tok` (with optional annotation) as the non-empty unannotated + * string a check slot requires; on success *out owns the string. `ctx` + * names the slot in messages, e.g. "check 'header' argument". */ +static struct st_error * +require_string_arg(const struct st_kdl_token_ref *tok, + const struct st_kdl_token_ref *ann, + const char *ctx, char **out) +{ + struct st_kdl_value v; + struct st_error *e; + char msg[192]; + + *out = NULL; + e = st_kdl_value_from_token_annotated(tok, ann, &v); + if (e != NULL) { + struct st_error *r = schema_err_from(e); + + st_error_free(e); + return r; + } + if (v.annotation != NULL) { + snprintf(msg, sizeof msg, "%s must not carry a type annotation", ctx); + e = err_at_owned(tok->span, msg); + st_kdl_value_free(&v); + return e; + } + if (v.kind != ST_KDL_VAL_STRING) { + snprintf(msg, sizeof msg, "%s must be a non-empty string (got %s)", + ctx, st_kdl_value_kind_name(v.kind)); + e = err_at_owned(tok->span, msg); + st_kdl_value_free(&v); + return e; + } + if (v.as.str[0] == '\0') { + snprintf(msg, sizeof msg, "%s must be a non-empty string", ctx); + e = err_at_owned(tok->span, msg); + st_kdl_value_free(&v); + return e; + } + *out = v.as.str; + v.as.str = NULL; + st_kdl_value_free(&v); /* frees the annotation only */ + return NULL; +} + +/* ---- token safety checks (C-embedding boundaries) --------------------- */ + +/* True when `s` contains a control byte (including NUL); these cannot + * appear in any C source token we embed verbatim. */ +static bool +has_control_byte(const char *s) +{ + const unsigned char *p = (const unsigned char *)s; + + for (; *p != '\0'; p++) { + if (*p < 0x20u || *p == 0x7fu) { + return true; + } + } + return false; +} + +/* True when `s` is a plain header name (only [A-Za-z0-9_./+-]). */ +static bool +is_plain_header_name(const char *s) +{ + const unsigned char *p = (const unsigned char *)s; + + if (*p == '\0') { + return false; + } + for (; *p != '\0'; p++) { + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '_' || *p == '.' || + *p == '/' || *p == '+' || *p == '-')) { + return false; + } + } + return true; +} + +/* True when `s` is a valid C identifier (letter/underscore, then letters, + * digits, underscores). */ +static bool +is_c_identifier(const char *s) +{ + const unsigned char *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; +} + +/* Build the include target for a header name: `` for plain names, + * `"..."` (with `"` and `\` C-escaped) otherwise. Returns a malloc'd + * string, or NULL with *err set when the name carries a control byte. */ +static char * +header_ref(const char *name, struct st_error **err) +{ + if (err != NULL) { + *err = NULL; + } + if (has_control_byte(name)) { + char msg[192]; + + snprintf(msg, sizeof msg, + "check 'header': header name contains a control character"); + if (err != NULL) { + *err = st_error_kdl_schema(msg); + } + return NULL; + } + if (is_plain_header_name(name)) { + return strbuild("<%s>", name); + } + { + /* escape `\` and `"` for the quoted include form */ + size_t i; + size_t cap = strlen(name) * 2 + 3; /* 2x for worst-case escapes + "" NUL */ + char *out = malloc(cap); + size_t n = 0; + + if (out == NULL) { + return NULL; + } + out[n++] = '"'; + for (i = 0; name[i] != '\0'; i++) { + if (name[i] == '\\' || name[i] == '"') { + out[n++] = '\\'; + } + out[n++] = name[i]; + } + out[n++] = '"'; + out[n] = '\0'; + return out; + } +} + +/* ---- version constraint parsing --------------------------------------- */ + +/* Parse "". On success returns 0 and sets *ver (a pointer + * into `s`, NUL-terminated because `s` is). The version part must be + * non-empty dotted-numeric (digits and '.', at least one digit). Returns + * -1 otherwise. */ +static int +parse_constraint(const char *s, const char **ver) +{ + size_t ol = 0; + const char *v; + size_t i; + size_t ndigits = 0; + + if (s[0] == '>' && s[1] == '=') { + ol = 2; + } else if (s[0] == '<' && s[1] == '=') { + ol = 2; + } else if (s[0] == '>' || s[0] == '<' || s[0] == '=') { + ol = 1; + } else { + return -1; + } + v = s + ol; + if (v[0] == '\0') { + return -1; + } + for (i = 0; v[i] != '\0'; i++) { + if (v[i] >= '0' && v[i] <= '9') { + ndigits++; + } else if (v[i] != '.') { + return -1; + } + } + if (ndigits == 0) { + return -1; + } + *ver = v; + return 0; +} + +/* ---- per-kind source builders ----------------------------------------- */ + +/* The trivial main shared by library and compiler_flag probes. */ +static char * +trivial_main_source(void) +{ + return xstrdup( + "int main(void)\n" + "{\n" + " return 0;\n" + "}\n"); +} + +/* ---- public API ------------------------------------------------------- */ + +struct st_error * +st_check_probe_build(enum st_check_kind kind, const struct st_kdl_node *node, + struct st_check_probe *out) +{ + struct st_check_probe tmp = { ST_PROBE_PREPROCESS, NULL, NULL, NULL, + NULL }; + const struct st_check_shape *shape; + struct st_error *err = NULL; + char *arg = NULL; + char *constraint = NULL; /* raw "" for library/pkg_config */ + const char *con_ver = NULL; /* parsed version pointer into `constraint` */ + char ctx[96]; + + if (out != NULL) { + *out = (struct st_check_probe){ ST_PROBE_PREPROCESS, NULL, NULL, NULL, + NULL }; + } + if ((size_t)kind >= ST_CHECK_KIND_COUNT) { + return st_error_kdl_schema( + "st_check_probe_build: unknown check kind"); + } + if (node == NULL) { + return st_error_kdl_schema("check node is NULL"); + } + + shape = &st_check_shapes[kind]; + + /* the target argument */ + if (node->args == NULL) { + char msg[192]; + + snprintf(msg, sizeof msg, + "check '%s' requires an argument (the %s)", shape->name, + shape->arg_meaning); + err = err_at_owned(node->name.span, msg); + goto done; + } + snprintf(ctx, sizeof ctx, "check '%s' argument", shape->name); + err = require_string_arg(&node->args->value, node->args->annotation, ctx, + &arg); + if (err != NULL) { + goto done; + } + + /* the optional `version` keyword + constraint pair (library/pkg_config) */ + if (shape->optional_args == 2 && node->args->next != NULL) { + const struct st_kdl_arg *vk = node->args->next; + char msg[192]; + + if (vk->annotation != NULL || + !(vk->value.kind == ST_TOK_IDENT && vk->value.len == 7 && + memcmp(vk->value.text, "version", 7) == 0)) { + snprintf(msg, sizeof msg, + "check '%s': expected the keyword 'version' as the second " + "argument", shape->name); + err = err_at_owned(vk->value.span, msg); + goto done; + } + if (vk->next == NULL) { + snprintf(msg, sizeof msg, + "check '%s': 'version' requires the version constraint " + "argument", shape->name); + err = err_at_owned(vk->value.span, msg); + goto done; + } + snprintf(ctx, sizeof ctx, "check '%s' version", shape->name); + err = require_string_arg(&vk->next->value, vk->next->annotation, ctx, + &constraint); + if (err != NULL) { + goto done; + } + } + + /* Validate "" for BOTH kinds; `library` only records it + * (enforcement is todo 16), so this runs even where it is not applied. */ + if (constraint != NULL) { + char msg[192]; + + if (parse_constraint(constraint, &con_ver) != 0) { + snprintf(msg, sizeof msg, + "check '%s': malformed version constraint '%s' " + "(expected '', e.g. '>=1.0')", shape->name, + constraint); + err = err_at_owned(node->args->next->next->value.span, msg); + goto done; + } + } + + switch (kind) { + case ST_CHECK_HEADER: { + char *ref = header_ref(arg, &err); + + if (err != NULL) { + goto done; + } + if (ref == NULL) { + err = st_error_internal("out of memory building header probe"); + goto done; + } + tmp.mode = ST_PROBE_COMPILE; + tmp.c_source = strbuild("#include %s\n", ref); + free(ref); + if (tmp.c_source == NULL) { + err = st_error_internal("out of memory building header probe"); + goto done; + } + break; + } + case ST_CHECK_FUNCTION: { + char msg[192]; + + if (!is_c_identifier(arg)) { + snprintf(msg, sizeof msg, + "check 'function': function name '%s' is not a valid C " + "identifier", arg); + err = err_at_owned(node->args->value.span, msg); + goto done; + } + tmp.mode = ST_PROBE_LINK; + tmp.c_source = strbuild( + "extern void %s(void);\n" + "int main(void)\n" + "{\n" + " %s();\n" + " return 0;\n" + "}\n", + arg, arg); + if (tmp.c_source == NULL) { + err = st_error_internal("out of memory building function probe"); + goto done; + } + tmp.extra_args = calloc(2, sizeof(char *)); + if (tmp.extra_args == NULL) { + err = st_error_internal("out of memory building function probe"); + goto done; + } + tmp.extra_args[0] = xstrdup("-fno-builtin"); + if (tmp.extra_args[0] == NULL) { + err = st_error_internal("out of memory building function probe"); + goto done; + } + break; + } + case ST_CHECK_LIBRARY: { + tmp.mode = ST_PROBE_LINK; + tmp.c_source = trivial_main_source(); + if (tmp.c_source == NULL) { + err = st_error_internal("out of memory building library probe"); + goto done; + } + tmp.extra_args = calloc(2, sizeof(char *)); + if (tmp.extra_args == NULL) { + err = st_error_internal("out of memory building library probe"); + goto done; + } + tmp.extra_args[0] = strbuild("-l%s", arg); + if (tmp.extra_args[0] == NULL) { + err = st_error_internal("out of memory building library probe"); + goto done; + } + if (constraint != NULL) { + tmp.version_constraint = xstrdup(constraint); + if (tmp.version_constraint == NULL) { + err = st_error_internal("out of memory building library " + "probe"); + goto done; + } + } + break; + } + case ST_CHECK_TYPE: { + char msg[192]; + + if (has_control_byte(arg)) { + snprintf(msg, sizeof msg, + "check 'type': type name contains a control character"); + err = err_at_owned(node->args->value.span, msg); + goto done; + } + tmp.mode = ST_PROBE_COMPILE; + tmp.c_source = strbuild( + "_Static_assert(sizeof(%s) != 0, \"type must be a complete " + "object type\");\n", + arg); + if (tmp.c_source == NULL) { + err = st_error_internal("out of memory building type probe"); + goto done; + } + break; + } + case ST_CHECK_SIZEOF: { + char msg[192]; + + if (has_control_byte(arg)) { + snprintf(msg, sizeof msg, + "check 'sizeof': type name contains a control character"); + err = err_at_owned(node->args->value.span, msg); + goto done; + } + tmp.mode = ST_PROBE_RUN; + tmp.c_source = strbuild( + "#include \n" + "_Static_assert(sizeof(%s) != 0, \"type must be a complete " + "object type\");\n" + "int main(void)\n" + "{\n" + " printf(\"%%zu\\n\", sizeof(%s));\n" + " return 0;\n" + "}\n", + arg, arg); + if (tmp.c_source == NULL) { + err = st_error_internal("out of memory building sizeof probe"); + goto done; + } + break; + } + case ST_CHECK_PROGRAM: { + tmp.mode = ST_PROBE_COMMAND; + tmp.command = calloc(4, sizeof(char *)); + if (tmp.command == NULL) { + err = st_error_internal("out of memory building program probe"); + goto done; + } + tmp.command[0] = xstrdup("command"); + tmp.command[1] = xstrdup("-v"); + tmp.command[2] = xstrdup(arg); + if (tmp.command[0] == NULL || tmp.command[1] == NULL || + tmp.command[2] == NULL) { + err = st_error_internal("out of memory building program probe"); + goto done; + } + break; + } + case ST_CHECK_COMPILER_FLAG: { + tmp.mode = ST_PROBE_COMPILE; + tmp.c_source = trivial_main_source(); + if (tmp.c_source == NULL) { + err = st_error_internal("out of memory building compiler_flag " + "probe"); + goto done; + } + tmp.extra_args = calloc(2, sizeof(char *)); + if (tmp.extra_args == NULL) { + err = st_error_internal("out of memory building compiler_flag " + "probe"); + goto done; + } + tmp.extra_args[0] = xstrdup(arg); + if (tmp.extra_args[0] == NULL) { + err = st_error_internal("out of memory building compiler_flag " + "probe"); + goto done; + } + break; + } + case ST_CHECK_PKG_CONFIG: { + const char *ver_flag = NULL; + + if (constraint != NULL) { + if (constraint[0] == '>' && constraint[1] == '=') { + ver_flag = "--atleast-version="; + } else if (constraint[0] == '<' && constraint[1] == '=') { + ver_flag = "--max-version="; + } else if (constraint[0] == '=') { + ver_flag = "--exact-version="; + } + /* strict '>' and '<' have no pkg-config flag: recorded only */ + tmp.version_constraint = xstrdup(constraint); + if (tmp.version_constraint == NULL) { + err = st_error_internal("out of memory building pkg_config " + "probe"); + goto done; + } + } + tmp.mode = ST_PROBE_COMMAND; + if (ver_flag != NULL) { + char *flag = strbuild("%s%s", ver_flag, con_ver); + + if (flag == NULL) { + err = st_error_internal("out of memory building pkg_config " + "probe"); + goto done; + } + tmp.command = calloc(6, sizeof(char *)); + if (tmp.command == NULL) { + free(flag); + err = st_error_internal("out of memory building pkg_config " + "probe"); + goto done; + } + tmp.command[0] = xstrdup("pkg-config"); + tmp.command[1] = flag; + tmp.command[2] = xstrdup("--cflags"); + tmp.command[3] = xstrdup("--libs"); + tmp.command[4] = xstrdup(arg); + if (tmp.command[0] == NULL || tmp.command[2] == NULL || + tmp.command[3] == NULL || tmp.command[4] == NULL) { + err = st_error_internal("out of memory building pkg_config " + "probe"); + goto done; + } + } else { + tmp.command = calloc(5, sizeof(char *)); + if (tmp.command == NULL) { + err = st_error_internal("out of memory building pkg_config " + "probe"); + goto done; + } + tmp.command[0] = xstrdup("pkg-config"); + tmp.command[1] = xstrdup("--cflags"); + tmp.command[2] = xstrdup("--libs"); + tmp.command[3] = xstrdup(arg); + if (tmp.command[0] == NULL || tmp.command[1] == NULL || + tmp.command[2] == NULL || tmp.command[3] == NULL) { + err = st_error_internal("out of memory building pkg_config " + "probe"); + goto done; + } + } + break; + } + default: + err = st_error_kdl_schema("st_check_probe_build: unknown check kind"); + goto done; + } + +done: + free(arg); + free(constraint); + if (err != NULL) { + st_check_probe_free(&tmp); + return err; + } + if (out != NULL) { + *out = tmp; + } else { + st_check_probe_free(&tmp); + } + return NULL; +} + +void +st_check_probe_free(struct st_check_probe *p) +{ + if (p == NULL) { + return; + } + free(p->c_source); + free(p->version_constraint); + argv_free(p->command); + argv_free(p->extra_args); + p->mode = ST_PROBE_PREPROCESS; + p->c_source = NULL; + p->version_constraint = NULL; + p->command = NULL; + p->extra_args = NULL; +} + +const char * +st_check_probe_mode_name(enum st_check_probe_mode mode) +{ + switch (mode) { + case ST_PROBE_PREPROCESS: + return "preprocess"; + case ST_PROBE_COMPILE: + return "compile"; + case ST_PROBE_LINK: + return "link"; + case ST_PROBE_RUN: + return "run"; + case ST_PROBE_COMMAND: + return "command"; + default: + return "?"; + } +} diff --git a/src/detect/checks.h b/src/detect/checks.h new file mode 100644 index 0000000..a9c5677 --- /dev/null +++ b/src/detect/checks.h @@ -0,0 +1,131 @@ +/* + * checks.h - probe-source specs for the 8 check kinds (todo 11). + * + * Given a feature's CHECK node (already schema-validated by src/kdl/schema.c), + * this module builds the exact PROBE SPEC that todo 12 (probe code generator) + * turns into configure-time compile/link/run/command invocations and todo 16 + * (configure assembly) substitutes the results of. It builds SPECS ONLY: it + * never executes a probe, never touches the filesystem beyond heap + * allocation, and never emits shell text. + * + * THE PROBE SPEC (the contract todo 12/16 consume) + * ------------------------------------------------ + * + * enum st_check_probe_mode { + * ST_PROBE_PREPROCESS, // -E: header existence via the preprocessor + * ST_PROBE_COMPILE, // compile a C translation unit (-c) + * ST_PROBE_LINK, // compile + link a C program + * ST_PROBE_RUN, // compile + link + RUN, capture stdout + * ST_PROBE_COMMAND, // run an external command via execvp (argv) + * }; + * + * struct st_check_probe { + * enum st_check_probe_mode mode; + * char *c_source; // C snippet; NULL for COMMAND probes + * char **command; // argv (NULL-terminated); NULL unless + * // mode == ST_PROBE_COMMAND + * char **extra_args; // argv (NULL-terminated) of extra + * // compile/link flags (e.g. "-lpthread", + * // "-fsanitize=address", + * // "-fno-builtin"); NULL when empty + * char *version_constraint; // raw "" string for + * // library/pkg_config; NULL when absent + * }; + * + * - `command` and `extra_args` are exec-style argv arrays (char **, first + * element is argv[0] for `command`, a flag for `extra_args`; both + * NULL-terminated). The program/package name a COMMAND probe targets is + * carried as a SINGLE argv element, never concatenated into a shell + * string — todo 12/16 shell-quote it at emission (st_sh_quote) so + * injection is structurally impossible here. + * - `c_source` is a complete, self-contained C23 translation unit (or a + * standalone declaration for compile-only probes) that compiles clean + * under `-std=c23 -Wall -Wextra -Wpedantic`. + * + * PER-KIND SPEC TABLE + * ------------------- + * header "pthread.h" COMPILE `#include ` (angle form for + * plain names; `"..."` escaped form for + * names with spaces/quotes/metachars) + * function "strdup" LINK `extern void strdup(void); ...` + * + a call; extra_args = {"-fno-builtin"} + * library "pthread" LINK trivial `int main(void){return 0;}` + * + extra_args = {"-lpthread"} + * type "size_t" COMPILE `_Static_assert(sizeof(size_t) != 0,...)` + * sizeof "long" RUN `_Static_assert(...)` sentinel + a + * printf of `sizeof(long)` (run path) + * program "pkg-config" COMMAND argv {command, -v, pkg-config} + * compiler_flag "-fsani..." COMPILE trivial main + extra_args = {flag} + * pkg_config "openssl" COMMAND argv {pkg-config, --cflags, --libs, pkg} + * + * OWNERSHIP + * --------- + * st_check_probe_build() writes a FULLY HEAP-OWNED probe into a caller + * provided struct; st_check_probe_free() releases it (NULL is a safe no-op). + * On error `out` is left zeroed and an owned st_error is returned. + * + * ERRORS + * ------ + * Failures return an owned st_error of category ST_ERR_KDL_SCHEMA whose span + * is heap-allocated IN THE SAME BLOCK as the error (the parser.c pattern), at + * the offending token: a NULL node, a missing/non-string target argument, a + * malformed version constraint, a non-identifier function name, or a control + * character in an embedded C token. An out-of-range kind is the one span-less + * case (defensive; no source position exists). On allocation failure the + * error is a plain ST_ERR_INTERNAL with no span (and, as throughout this + * codebase, may itself be NULL under catastrophic OOM). + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef ST_DETECT_CHECKS_H +#define ST_DETECT_CHECKS_H + +#include + +#include "detect/check_registry.h" /* enum st_check_kind */ + +struct st_error; +struct st_kdl_node; + +/* The probe MODE: what todo 12 must do with the snippet to test the check. + * PREPROCESS is reserved for a future header fast-path; none of the 8 v1 + * kinds emit it (header uses COMPILE). */ +enum st_check_probe_mode { + ST_PROBE_PREPROCESS = 0, /* -E the snippet (unused by v1 kinds) */ + ST_PROBE_COMPILE, /* compile the snippet (-c) */ + ST_PROBE_LINK, /* compile + link the snippet */ + ST_PROBE_RUN, /* compile + link + run, capture stdout */ + ST_PROBE_COMMAND, /* exec the `command` argv */ +}; + +/* A built probe spec (see the header comment for the per-kind table). */ +struct st_check_probe { + enum st_check_probe_mode mode; + char *c_source; /* heap C snippet; NULL for COMMAND probes */ + char **command; /* heap argv (NULL-terminated); NULL unless + mode == ST_PROBE_COMMAND */ + char **extra_args; /* heap argv (NULL-terminated) of extra + compile/link flags; NULL when empty */ + char *version_constraint; /* heap raw ""; NULL when absent */ +}; + +/* Build the probe spec for `kind` from a schema-validated CHECK node. The + * node's FIRST positional argument is the target; for library/pkg_config the + * optional `version` keyword + constraint pair is read too. On success + * returns NULL and fills `*out` (a fully heap-owned probe). On failure + * returns an owned error (see ERRORS) and leaves `*out` zeroed. */ +struct st_error *st_check_probe_build(enum st_check_kind kind, + const struct st_kdl_node *node, + struct st_check_probe *out); + +/* Release a probe spec produced by st_check_probe_build. NULL is a safe + * no-op; the probe is zeroed after freeing. */ +void st_check_probe_free(struct st_check_probe *p); + +/* Stable display name for a probe mode, e.g. "compile". Out-of-range modes + * yield "?" rather than indexing out of range. */ +const char *st_check_probe_mode_name(enum st_check_probe_mode mode); + +#endif /* ST_DETECT_CHECKS_H */ diff --git a/tests/unit/test_checks.c b/tests/unit/test_checks.c new file mode 100644 index 0000000..bd1d53f --- /dev/null +++ b/tests/unit/test_checks.c @@ -0,0 +1,482 @@ +/* LINK: ../../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/error.c ../../src/span.c */ + +#ifndef _POSIX_C_SOURCE +#define _POSIX_C_SOURCE 200809L /* popen/pclose, sys/wait.h */ +#endif + +/* + * tests/unit/test_checks.c + * + * Unit tests for the check registry implementation (todo 11): for each of + * the 8 check kinds, src/detect/checks.c builds the exact PROBE SPEC + * (mode + C snippet + command argv + extra args) that todo 12 turns into + * configure-time compile/link/run/command invocations. This module builds + * specs ONLY - it never executes a probe and never emits shell text. + * + * The magic LINK comment on line 1 is REQUIRED by tests/run.sh: it lists + * the extra .c sources compiled into this test binary (paths relative to + * tests/unit/). checks.c needs check_registry.c (the shape table) + value.c + * (arg extraction); parser.c + lexer.c build documents end-to-end from + * source text; error.c + span.c via error.c's st_span_print; schema.c + * linked per the task's file list. + */ +#include "munit.h" + +#include "detect/check_registry.h" +#include "detect/checks.h" +#include "error.h" +#include "kdl/ast.h" + +#include +#include +#include +#include + +/* ---- helpers ---------------------------------------------------------- */ + +/* Parse a single-node document and return its one node (parse success is + * asserted). The caller frees the returned document. */ +static struct st_kdl_node * +parse_one_node(const char *src, struct st_kdl_document **doc_out) +{ + struct st_error *err = NULL; + + *doc_out = st_kdl_parse(src, "t.kdl", &err); + munit_assert_null(err); + munit_assert_not_null(*doc_out); + return (*doc_out)->nodes; +} + +/* Parse `src` as a single check node, resolve its kind, and build its + * probe spec into `*out`. Asserts parse + kind resolution + build success + * and frees the document before returning (the probe is fully heap-owned + * and independent of the document). Returns the resolved kind. */ +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; + + node = parse_one_node(src, &doc); + 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; +} + +/* Compile `src` through a real cc on stdin (compile-only), with an + * optional leading flag ("" for none, or e.g. "-fno-builtin "). Returns + * the compiler's exit status, or -1 on any setup failure. The cc command + * is a fixed literal; `src` reaches cc via stdin, never via the shell. */ +static int +cc_compile_stdin(const char *src, const char *flag) +{ + char cmd[256]; + FILE *p; + int n; + int rc; + size_t len = strlen(src); + + n = snprintf(cmd, sizeof cmd, + "cc -std=c23 -Wall -Wextra -Wpedantic %s-c -x c - -o /dev/null", + flag != NULL ? flag : ""); + if (n < 0 || (size_t)n >= sizeof cmd) { + return -1; + } + p = popen(cmd, "w"); + if (p == NULL) { + return -1; + } + if (fwrite(src, 1, len, p) != len) { + (void)pclose(p); + return -1; + } + rc = pclose(p); + if (rc == -1 || !WIFEXITED(rc)) { + return -1; + } + return WEXITSTATUS(rc); +} + +/* ---- tests ------------------------------------------------------------ */ + +/* (a) header "pthread.h" -> COMPILE probe whose source includes the + * header with the angle-bracket form. */ +static MunitResult +test_header(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("header \"pthread.h\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_HEADER); + munit_assert_int(p.mode, ==, ST_PROBE_COMPILE); + munit_assert_not_null(p.c_source); + munit_assert_true(strstr(p.c_source, "#include ") != NULL); + munit_assert_null(p.command); + munit_assert_null(p.extra_args); + munit_assert_null(p.version_constraint); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (b) function "strdup" -> LINK probe whose source declares and calls the + * symbol; -fno-builtin so GCC's builtin machinery can't reject/mis-type + * the declaration. */ +static MunitResult +test_function(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("function \"strdup\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_FUNCTION); + munit_assert_int(p.mode, ==, ST_PROBE_LINK); + munit_assert_not_null(p.c_source); + munit_assert_true(strstr(p.c_source, "extern void strdup(void)") != NULL); + munit_assert_true(strstr(p.c_source, "strdup();") != NULL); + munit_assert_not_null(p.extra_args); + munit_assert_string_equal(p.extra_args[0], "-fno-builtin"); + munit_assert_null(p.extra_args[1]); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (c) library "pthread" -> LINK probe with extra_arg "-lpthread". */ +static MunitResult +test_library(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("library \"pthread\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_LIBRARY); + munit_assert_int(p.mode, ==, ST_PROBE_LINK); + munit_assert_not_null(p.c_source); + munit_assert_not_null(p.extra_args); + munit_assert_string_equal(p.extra_args[0], "-lpthread"); + munit_assert_null(p.extra_args[1]); + munit_assert_null(p.version_constraint); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (d) type "size_t" -> COMPILE probe using sizeof(size_t) in a static + * assertion. */ +static MunitResult +test_type(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("type \"size_t\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_TYPE); + munit_assert_int(p.mode, ==, ST_PROBE_COMPILE); + munit_assert_not_null(p.c_source); + munit_assert_true(strstr(p.c_source, "sizeof(size_t)") != NULL); + munit_assert_true(strstr(p.c_source, "_Static_assert") != NULL); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (e) sizeof "long" -> RUN-capable probe: a _Static_assert sentinel plus a + * printf run path. */ +static MunitResult +test_sizeof(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("sizeof \"long\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_SIZEOF); + munit_assert_int(p.mode, ==, ST_PROBE_RUN); + munit_assert_not_null(p.c_source); + munit_assert_true(strstr(p.c_source, "sizeof(long)") != NULL); + munit_assert_true(strstr(p.c_source, "_Static_assert") != NULL); + munit_assert_true(strstr(p.c_source, "printf") != NULL); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (f) program "pkg-config" -> COMMAND argv {command, -v, pkg-config}. */ +static MunitResult +test_program(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("program \"pkg-config\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_PROGRAM); + munit_assert_int(p.mode, ==, ST_PROBE_COMMAND); + munit_assert_null(p.c_source); + munit_assert_not_null(p.command); + munit_assert_string_equal(p.command[0], "command"); + munit_assert_string_equal(p.command[1], "-v"); + munit_assert_string_equal(p.command[2], "pkg-config"); + munit_assert_null(p.command[3]); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (g) compiler_flag "-fsanitize=address" -> COMPILE probe with the flag as + * an extra_arg. */ +static MunitResult +test_compiler_flag(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("compiler_flag \"-fsanitize=address\"", + &p); + + munit_assert_int(kind, ==, ST_CHECK_COMPILER_FLAG); + munit_assert_int(p.mode, ==, ST_PROBE_COMPILE); + munit_assert_not_null(p.c_source); + munit_assert_not_null(p.extra_args); + munit_assert_string_equal(p.extra_args[0], "-fsanitize=address"); + munit_assert_null(p.extra_args[1]); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* (h) pkg_config "openssl" -> COMMAND argv {pkg-config, --cflags, --libs, + * openssl}. */ +static MunitResult +test_pkg_config(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + enum st_check_kind kind = build_from("pkg_config \"openssl\"", &p); + + munit_assert_int(kind, ==, ST_CHECK_PKG_CONFIG); + munit_assert_int(p.mode, ==, ST_PROBE_COMMAND); + munit_assert_null(p.c_source); + munit_assert_not_null(p.command); + munit_assert_string_equal(p.command[0], "pkg-config"); + munit_assert_string_equal(p.command[1], "--cflags"); + munit_assert_string_equal(p.command[2], "--libs"); + munit_assert_string_equal(p.command[3], "openssl"); + munit_assert_null(p.command[4]); + munit_assert_null(p.version_constraint); + st_check_probe_free(&p); + return MUNIT_OK; +} + +/* The optional `version` constraint: library records it (and still links + * with plain -l); pkg_config maps >= to --atleast-version=. */ +static MunitResult +test_version_constraints(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe lib = { 0 }; + struct st_check_probe pk = { 0 }; + struct st_check_probe pkeq = { 0 }; + + (void)build_from("library \"pthread\" version \">=2.0\"", &lib); + munit_assert_int(lib.mode, ==, ST_PROBE_LINK); + munit_assert_string_equal(lib.extra_args[0], "-lpthread"); + munit_assert_string_equal(lib.version_constraint, ">=2.0"); + + (void)build_from("pkg_config \"openssl\" version \">=1.1\"", &pk); + munit_assert_int(pk.mode, ==, ST_PROBE_COMMAND); + munit_assert_string_equal(pk.command[0], "pkg-config"); + munit_assert_string_equal(pk.command[1], "--atleast-version=1.1"); + munit_assert_string_equal(pk.command[2], "--cflags"); + munit_assert_string_equal(pk.command[3], "--libs"); + munit_assert_string_equal(pk.command[4], "openssl"); + munit_assert_null(pk.command[5]); + munit_assert_string_equal(pk.version_constraint, ">=1.1"); + + (void)build_from("pkg_config \"openssl\" version \"=1.2\"", &pkeq); + munit_assert_string_equal(pkeq.command[1], "--exact-version=1.2"); + + st_check_probe_free(&lib); + st_check_probe_free(&pk); + st_check_probe_free(&pkeq); + return MUNIT_OK; +} + +/* A malformed version constraint (no operator) is rejected with a schema + * error naming the check. */ +static MunitResult +test_malformed_constraint(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc = NULL; + struct st_kdl_node *node; + struct st_check_probe p = { 0 }; + + node = parse_one_node("library \"l\" version \"7.0\"", &doc); + err = st_check_probe_build(ST_CHECK_LIBRARY, node, &p); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_true(strstr(st_error_message(err), "malformed version " + "constraint") != NULL); + st_error_free(err); + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (j) unknown kind / NULL node / missing argument all error cleanly. */ +static MunitResult +test_bad_input(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_check_probe p = { 0 }; + struct st_kdl_document *doc = NULL; + struct st_kdl_node *node; + + err = st_check_probe_build(ST_CHECK_KIND_COUNT, NULL, &p); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + st_error_free(err); + + err = st_check_probe_build(ST_CHECK_HEADER, NULL, &p); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + st_error_free(err); + + node = parse_one_node("header", &doc); + err = st_check_probe_build(ST_CHECK_HEADER, node, &p); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_true(strstr(st_error_message(err), "requires an argument") + != NULL); + st_error_free(err); + st_kdl_document_free(doc); + return MUNIT_OK; +} + +/* (i) COMPILE-PROOF: three generated snippets actually compile under + * `cc -std=c23 -Wall -Wextra -Wpedantic -c -x c -` (exit 0); the function + * snippet additionally compiles clean with its own -fno-builtin arg. */ +static MunitResult +test_compile_proof(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe hdr = { 0 }; + struct st_check_probe lib = { 0 }; + struct st_check_probe sz = { 0 }; + struct st_check_probe fn = { 0 }; + + (void)build_from("header \"pthread.h\"", &hdr); + (void)build_from("library \"pthread\"", &lib); + (void)build_from("sizeof \"long\"", &sz); + (void)build_from("function \"strdup\"", &fn); + + munit_assert_int(cc_compile_stdin(hdr.c_source, ""), ==, 0); + munit_assert_int(cc_compile_stdin(lib.c_source, ""), ==, 0); + munit_assert_int(cc_compile_stdin(sz.c_source, ""), ==, 0); + munit_assert_int(cc_compile_stdin(fn.c_source, "-fno-builtin "), ==, 0); + + st_check_probe_free(&hdr); + st_check_probe_free(&lib); + st_check_probe_free(&sz); + st_check_probe_free(&fn); + return MUNIT_OK; +} + +/* Adversarial: a header name with metacharacters yields a single-line + * quoted include (no injection, valid C); a non-identifier function name + * errors; a COMMAND probe carries its program name as ONE argv element. */ +static MunitResult +test_adversarial(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_check_probe p = { 0 }; + struct st_error *err = NULL; + struct st_kdl_document *doc = NULL; + size_t len; + + (void)build_from("header \"\\\"; rm -rf /\"", &p); + munit_assert_int(p.mode, ==, ST_PROBE_COMPILE); + munit_assert_true(strncmp(p.c_source, "#include \"", 10) == 0); + munit_assert_true(strstr(p.c_source, "rm -rf /") != NULL); + munit_assert_null(strchr(p.c_source, '<')); + len = strlen(p.c_source); + munit_assert_true(len >= 1); + munit_assert_true(p.c_source[len - 1] == '\n'); + munit_assert_true(strchr(p.c_source, '\n') == &p.c_source[len - 1]); + st_check_probe_free(&p); + + doc = st_kdl_parse("function \"foo bar\"", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + err = st_check_probe_build(ST_CHECK_FUNCTION, doc->nodes, &p); + munit_assert_not_null(err); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_true(strstr(st_error_message(err), "not a valid C " + "identifier") != NULL); + st_error_free(err); + st_kdl_document_free(doc); + + doc = NULL; + err = NULL; + doc = st_kdl_parse("program \"pkg-config; rm -rf /\"", "t.kdl", &err); + munit_assert_not_null(doc); + munit_assert_null(err); + munit_assert_null(st_check_probe_build(ST_CHECK_PROGRAM, doc->nodes, &p)); + munit_assert_int(p.mode, ==, ST_PROBE_COMMAND); + munit_assert_string_equal(p.command[2], "pkg-config; rm -rf /"); + munit_assert_null(p.command[3]); + st_check_probe_free(&p); + st_kdl_document_free(doc); + return MUNIT_OK; +} + +static MunitTest tests[] = { + { "/checks/header", test_header, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { "/checks/function", test_function, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { "/checks/library", test_library, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { "/checks/type", test_type, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/checks/sizeof", test_sizeof, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { "/checks/program", test_program, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { "/checks/compiler-flag", test_compiler_flag, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/checks/pkg-config", test_pkg_config, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/checks/version-constraints", test_version_constraints, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/checks/malformed-constraint", test_malformed_constraint, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/checks/bad-input", test_bad_input, NULL, NULL, MUNIT_TEST_OPTION_NONE, + NULL }, + { "/checks/compile-proof", test_compile_proof, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/checks/adversarial", test_adversarial, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, +}; + +static const MunitSuite suite = { + "/checks", 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); +}