feat(detect): add feature resolution and when-guards

This commit is contained in:
2026-08-28 23:17:15 -04:00
parent ac0e0fd39a
commit 2fff47460e
4 changed files with 1836 additions and 12 deletions
+869
View File
@@ -0,0 +1,869 @@
/*
* resolve.c - feature resolution + `when` guards (todo 14).
*
* Pure code generator: it PARSES the `when` guard grammar into an AST and
* EMITS POSIX-sh that evaluates guards and aggregates feature results at
* CONFIGURE time. It never runs a probe and never evaluates a guard
* itself. See resolve.h for the guard grammar, the os-mapping table, the
* $st_os contract, the emitted guard shape, and the feature-aggregation
* contract that todo 16 consumes.
*
* Safety decisions:
* - Guard leaves emit `[ "$have_<ident>" = "yes" ]` and
* `[ "$st_os" = '<name>' ]`; the ident is validated as a POSIX shell
* identifier (it lands in a variable-NAME position and cannot be
* quoted), the os name is validated against the same charset AND
* single-quoted on emission (belt and braces).
* - Binary nodes re-parenthesize with `( ... )` because POSIX sh gives
* `&&`/`||` equal left-associative precedence, unlike the guard
* grammar's or-over-and hierarchy.
* - The os table is a single source of truth: st_os_normalize (C) and
* st_resolve_emit_os_norm (shell) both derive from os_map[].
*
* Copyright (c) 2026 huntedbytheirs
* SPDX-License-Identifier: BSD-3-Clause
*/
#include "detect/resolve.h"
#include "error.h"
#include "gen/sh_emit.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---- the os mapping table (single source of truth) -------------------- */
struct st_os_entry {
const char *uname_s; /* raw `uname -s` output */
const char *norm; /* canonical name for os= guards */
};
static const struct st_os_entry os_map[] = {
{ "Linux", "linux" },
{ "Darwin", "macos" },
{ "FreeBSD", "bsd" },
{ "OpenBSD", "bsd" },
{ "NetBSD", "bsd" },
};
#define OS_MAP_COUNT (sizeof(os_map) / sizeof(os_map[0]))
const char *
st_os_normalize(const char *uname_s)
{
size_t i;
if (uname_s == NULL) {
return "other";
}
for (i = 0; i < OS_MAP_COUNT; i++) {
if (strcmp(uname_s, os_map[i].uname_s) == 0) {
return os_map[i].norm;
}
}
return "other";
}
/* ---- identifiers ------------------------------------------------------ */
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;
}
/* Length-bounded identifier check (word slices are not NUL-terminated). */
static bool
valid_ident_n(const char *s, size_t n)
{
size_t i;
if (s == NULL || n == 0) {
return false;
}
if (!((s[0] >= 'a' && s[0] <= 'z') || (s[0] >= 'A' && s[0] <= 'Z') ||
s[0] == '_')) {
return false;
}
for (i = 1; i < n; i++) {
if (!((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') ||
(s[i] >= '0' && s[i] <= '9') || s[i] == '_')) {
return false;
}
}
return true;
}
/* ---- owned errors (same-block span, the parser.c pattern) ------------- */
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;
}
/* ---- the guard AST ---------------------------------------------------- */
enum st_when_kind {
ST_WHEN_HAVE = 0, /* have_<ident>: $have_<ident> = "yes" */
ST_WHEN_OS, /* os=<name>: $st_os = '<name>' */
ST_WHEN_NOT, /* ! <left> */
ST_WHEN_AND, /* <left> && <right> */
ST_WHEN_OR, /* <left> || <right> */
};
struct st_when_ast {
enum st_when_kind kind;
char *text; /* HAVE: ident; OS: name; else NULL */
struct st_when_ast *left; /* NOT operand; AND/OR left */
struct st_when_ast *right; /* AND/OR right */
};
static char *
dup_n(const char *s, size_t n)
{
char *p = malloc(n + 1);
if (p == NULL) {
return NULL;
}
memcpy(p, s, n);
p[n] = '\0';
return p;
}
static struct st_when_ast *
leaf(enum st_when_kind kind, const char *text, size_t len)
{
struct st_when_ast *a = calloc(1, sizeof(*a));
if (a == NULL) {
return NULL;
}
a->kind = kind;
if (text != NULL) {
a->text = dup_n(text, len);
if (a->text == NULL) {
free(a);
return NULL;
}
}
return a;
}
void
st_when_free(struct st_when_ast *a)
{
if (a == NULL) {
return;
}
st_when_free(a->left);
st_when_free(a->right);
free(a->text);
free(a);
}
/* ---- the guard lexer -------------------------------------------------- */
enum gtok_kind {
GTOK_EOF = 0,
GTOK_HAVE, /* text = ident (slice) */
GTOK_OS, /* text = name (slice) */
GTOK_AND,
GTOK_OR,
GTOK_NOT,
GTOK_LPAREN,
GTOK_RPAREN,
GTOK_UNKNOWN, /* word that is none of the above */
GTOK_HAVE_EMPTY, /* word == "have_" */
GTOK_HAVE_BAD, /* have_ + invalid ident */
GTOK_OS_EMPTY, /* word == "os=" */
GTOK_OS_BAD, /* os= + invalid name */
};
struct gtok {
enum gtok_kind kind;
size_t off; /* byte offset of the token start in the guard */
const char *text; /* slice into the guard (word, or ident/name) */
size_t len;
};
struct glex {
const char *s;
size_t len;
size_t pos;
struct gtok cur;
};
static bool
is_space(unsigned char c)
{
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' ||
c == '\f';
}
/* Classify a word slice (not NUL-terminated). For HAVE/OS success the
* text/len are narrowed to the ident/name; for error kinds they stay the
* whole word so the parser can echo it. */
static void
classify_word(struct gtok *t, const char *w, size_t wlen, size_t off)
{
t->off = off;
t->text = w;
t->len = wlen;
if (wlen == 3 && memcmp(w, "and", 3) == 0) {
t->kind = GTOK_AND;
} else if (wlen == 2 && memcmp(w, "or", 2) == 0) {
t->kind = GTOK_OR;
} else if (wlen == 3 && memcmp(w, "not", 3) == 0) {
t->kind = GTOK_NOT;
} else if (wlen >= 5 && memcmp(w, "have_", 5) == 0) {
if (wlen == 5) {
t->kind = GTOK_HAVE_EMPTY;
} else if (!valid_ident_n(w + 5, wlen - 5)) {
t->kind = GTOK_HAVE_BAD;
} else {
t->kind = GTOK_HAVE;
t->text = w + 5;
t->len = wlen - 5;
}
} else if (wlen >= 3 && memcmp(w, "os=", 3) == 0) {
if (wlen == 3) {
t->kind = GTOK_OS_EMPTY;
} else if (!valid_ident_n(w + 3, wlen - 3)) {
t->kind = GTOK_OS_BAD;
} else {
t->kind = GTOK_OS;
t->text = w + 3;
t->len = wlen - 3;
}
} else {
t->kind = GTOK_UNKNOWN;
}
}
static void
next_tok(struct glex *lx)
{
const char *s = lx->s;
size_t n = lx->len;
size_t i = lx->pos;
size_t start;
while (i < n && is_space((unsigned char)s[i])) {
i++;
}
lx->pos = i;
lx->cur.text = NULL;
lx->cur.len = 0;
lx->cur.kind = GTOK_EOF;
if (i >= n) {
lx->cur.off = n;
return;
}
if (s[i] == '(') {
lx->cur.kind = GTOK_LPAREN;
lx->cur.off = i;
lx->cur.text = s + i;
lx->cur.len = 1;
lx->pos = i + 1;
return;
}
if (s[i] == ')') {
lx->cur.kind = GTOK_RPAREN;
lx->cur.off = i;
lx->cur.text = s + i;
lx->cur.len = 1;
lx->pos = i + 1;
return;
}
start = i;
while (i < n && !is_space((unsigned char)s[i]) && s[i] != '(' &&
s[i] != ')') {
i++;
}
lx->pos = i;
classify_word(&lx->cur, s + start, i - start, start);
}
/* ---- the guard parser ------------------------------------------------- */
struct parse_ctx {
struct glex lx;
const struct st_span *base; /* NULL -> offset span into the guard */
};
/* 1-based line/col of byte offset `off` within `s`. */
static struct st_span
off_span(const char *s, size_t off)
{
struct st_span sp = { NULL, 1, 1 };
size_t i;
for (i = 0; i < off && s[i] != '\0'; i++) {
if (s[i] == '\n') {
sp.line++;
sp.col = 1;
} else {
sp.col++;
}
}
return sp;
}
static struct st_error *
parse_error(const struct parse_ctx *pc, size_t off, const char *msg)
{
struct st_span sp;
if (pc->base != NULL) {
sp = *pc->base;
} else {
sp = off_span(pc->lx.s, off);
}
return err_at_owned(sp, msg);
}
/* Copy the current token's word into `buf` for message text. */
static const char *
word_into(const struct gtok *t, char *buf, size_t cap)
{
size_t n = t->len < cap - 1 ? t->len : cap - 1;
if (t->text == NULL) {
n = 0;
}
if (n > 0) {
memcpy(buf, t->text, n);
}
buf[n] = '\0';
return buf;
}
static struct st_error *
unexpected(struct parse_ctx *pc)
{
const struct gtok *t = &pc->lx.cur;
char w[65];
char msg[160];
switch (t->kind) {
case GTOK_UNKNOWN:
snprintf(msg, sizeof msg, "unknown token '%s' in when guard",
word_into(t, w, sizeof w));
break;
case GTOK_HAVE_EMPTY:
snprintf(msg, sizeof msg,
"missing feature name after 'have_' in when guard");
break;
case GTOK_HAVE_BAD:
snprintf(msg, sizeof msg,
"invalid feature name '%s' after 'have_' in when guard",
word_into(t, w, sizeof w));
break;
case GTOK_OS_EMPTY:
snprintf(msg, sizeof msg, "missing os name after 'os=' in when "
"guard");
break;
case GTOK_OS_BAD:
snprintf(msg, sizeof msg,
"invalid os name '%s' after 'os=' in when guard",
word_into(t, w, sizeof w));
break;
case GTOK_EOF:
snprintf(msg, sizeof msg, "unexpected end of when guard expression");
break;
default:
snprintf(msg, sizeof msg, "unexpected token '%s' in when guard",
word_into(t, w, sizeof w));
break;
}
return parse_error(pc, t->off, msg);
}
static struct st_error *
parse_or(struct parse_ctx *pc, struct st_when_ast **out);
static struct st_error *
parse_primary(struct parse_ctx *pc, struct st_when_ast **out)
{
const struct gtok *t = &pc->lx.cur;
*out = NULL;
switch (t->kind) {
case GTOK_LPAREN: {
struct st_when_ast *inner = NULL;
struct st_error *e;
next_tok(&pc->lx);
e = parse_or(pc, &inner);
if (e != NULL) {
return e;
}
if (pc->lx.cur.kind != GTOK_RPAREN) {
struct st_error *e2 = parse_error(pc, pc->lx.cur.off,
"expected ')' in when guard");
st_when_free(inner);
return e2;
}
next_tok(&pc->lx);
*out = inner;
return NULL;
}
case GTOK_HAVE: {
struct st_when_ast *a = leaf(ST_WHEN_HAVE, t->text, t->len);
if (a == NULL) {
return st_error_internal("out of memory parsing when guard");
}
next_tok(&pc->lx);
*out = a;
return NULL;
}
case GTOK_OS: {
struct st_when_ast *a = leaf(ST_WHEN_OS, t->text, t->len);
if (a == NULL) {
return st_error_internal("out of memory parsing when guard");
}
next_tok(&pc->lx);
*out = a;
return NULL;
}
default:
return unexpected(pc);
}
}
static struct st_error *
parse_not(struct parse_ctx *pc, struct st_when_ast **out)
{
if (pc->lx.cur.kind == GTOK_NOT) {
struct st_when_ast *inner = NULL;
struct st_when_ast *a;
struct st_error *e;
next_tok(&pc->lx);
e = parse_not(pc, &inner);
if (e != NULL) {
return e;
}
a = calloc(1, sizeof(*a));
if (a == NULL) {
st_when_free(inner);
return st_error_internal("out of memory parsing when guard");
}
a->kind = ST_WHEN_NOT;
a->left = inner;
*out = a;
return NULL;
}
return parse_primary(pc, out);
}
static struct st_error *
parse_and(struct parse_ctx *pc, struct st_when_ast **out)
{
struct st_when_ast *lhs = NULL;
struct st_error *e;
e = parse_not(pc, &lhs);
if (e != NULL) {
return e;
}
while (pc->lx.cur.kind == GTOK_AND) {
struct st_when_ast *rhs = NULL;
struct st_when_ast *join;
next_tok(&pc->lx);
e = parse_not(pc, &rhs);
if (e != NULL) {
st_when_free(lhs);
return e;
}
join = calloc(1, sizeof(*join));
if (join == NULL) {
st_when_free(lhs);
st_when_free(rhs);
return st_error_internal("out of memory parsing when guard");
}
join->kind = ST_WHEN_AND;
join->left = lhs;
join->right = rhs;
lhs = join;
}
*out = lhs;
return NULL;
}
static struct st_error *
parse_or(struct parse_ctx *pc, struct st_when_ast **out)
{
struct st_when_ast *lhs = NULL;
struct st_error *e;
e = parse_and(pc, &lhs);
if (e != NULL) {
return e;
}
while (pc->lx.cur.kind == GTOK_OR) {
struct st_when_ast *rhs = NULL;
struct st_when_ast *join;
next_tok(&pc->lx);
e = parse_and(pc, &rhs);
if (e != NULL) {
st_when_free(lhs);
return e;
}
join = calloc(1, sizeof(*join));
if (join == NULL) {
st_when_free(lhs);
st_when_free(rhs);
return st_error_internal("out of memory parsing when guard");
}
join->kind = ST_WHEN_OR;
join->left = lhs;
join->right = rhs;
lhs = join;
}
*out = lhs;
return NULL;
}
struct st_error *
st_when_parse_at(const char *guard, const struct st_span *at,
struct st_when_ast **out)
{
struct parse_ctx pc;
struct st_when_ast *ast = NULL;
struct st_error *e;
if (out == NULL) {
return st_error_usage("st_when_parse: NULL out");
}
*out = NULL;
if (guard == NULL || guard[0] == '\0') {
return NULL; /* absent guard = always true */
}
pc.lx.s = guard;
pc.lx.len = strlen(guard);
pc.lx.pos = 0;
pc.base = at;
next_tok(&pc.lx);
e = parse_or(&pc, &ast);
if (e != NULL) {
st_when_free(ast);
return e;
}
if (pc.lx.cur.kind != GTOK_EOF) {
e = unexpected(&pc);
st_when_free(ast);
return e;
}
*out = ast;
return NULL;
}
struct st_error *
st_when_parse(const char *guard, struct st_when_ast **out)
{
return st_when_parse_at(guard, NULL, out);
}
/* ---- guard emission --------------------------------------------------- */
static struct st_error *
emit_node(FILE *out, const struct st_when_ast *n)
{
switch (n->kind) {
case ST_WHEN_HAVE:
if (fprintf(out, "[ \"$have_%s\" = \"yes\" ]", n->text) < 0) {
return st_error_io("I/O error emitting when guard");
}
break;
case ST_WHEN_OS: {
char *q = st_sh_quote(n->text);
int rc;
if (q == NULL) {
return st_error_internal("out of memory quoting os name");
}
rc = fprintf(out, "[ \"$st_os\" = %s ]", q);
free(q);
if (rc < 0) {
return st_error_io("I/O error emitting when guard");
}
break;
}
case ST_WHEN_NOT: {
struct st_error *e;
if (fputs("! ( ", out) == EOF) {
return st_error_io("I/O error emitting when guard");
}
e = emit_node(out, n->left);
if (e != NULL) {
return e;
}
if (fputs(" )", out) == EOF) {
return st_error_io("I/O error emitting when guard");
}
break;
}
case ST_WHEN_AND:
case ST_WHEN_OR: {
const char *op = (n->kind == ST_WHEN_AND) ? "&&" : "||";
struct st_error *e;
if (fputs("( ", out) == EOF) {
return st_error_io("I/O error emitting when guard");
}
e = emit_node(out, n->left);
if (e != NULL) {
return e;
}
if (fprintf(out, " ) %s ( ", op) < 0) {
return st_error_io("I/O error emitting when guard");
}
e = emit_node(out, n->right);
if (e != NULL) {
return e;
}
if (fputs(" )", out) == EOF) {
return st_error_io("I/O error emitting when guard");
}
break;
}
default:
return st_error_internal("st_when_emit: corrupted guard AST");
}
return NULL;
}
struct st_error *
st_when_emit(FILE *out, const struct st_when_ast *ast)
{
if (out == NULL) {
return st_error_usage("st_when_emit: NULL stream");
}
if (ast == NULL) {
/* absent guard = always true; `:` is the no-op command */
if (fputs(":", out) == EOF) {
return st_error_io("I/O error emitting when guard");
}
return NULL;
}
return emit_node(out, ast);
}
/* ---- os normalization boilerplate ------------------------------------- */
struct st_error *
st_resolve_emit_os_norm(FILE *out)
{
size_t i;
if (out == NULL) {
return st_error_usage("st_resolve_emit_os_norm: NULL stream");
}
if (fputs(
"# st_os holds the raw `uname -s` output (set by the configure\n"
"# preamble). st_os_norm rewrites it IN PLACE to the canonical\n"
"# name that `os=<name>` guards compare against.\n"
"st_os_norm() {\n"
" case \"$st_os\" in\n",
out) == EOF) {
return st_error_io("I/O error emitting st_os_norm");
}
for (i = 0; i < OS_MAP_COUNT; i++) {
char *pat = st_sh_quote(os_map[i].uname_s);
char *val = st_sh_quote(os_map[i].norm);
int rc;
if (pat == NULL || val == NULL) {
free(pat);
free(val);
return st_error_internal("out of memory emitting st_os_norm");
}
rc = fprintf(out, " %s) st_os=%s ;;\n", pat, val);
free(pat);
free(val);
if (rc < 0) {
return st_error_io("I/O error emitting st_os_norm");
}
}
if (fputs(" *) st_os='other' ;;\n"
" esac\n"
"}\n",
out) == EOF) {
return st_error_io("I/O error emitting st_os_norm");
}
if (ferror(out)) {
return st_error_io("I/O error emitting st_os_norm");
}
return NULL;
}
/* ---- feature aggregation ---------------------------------------------- */
int
st_resolve_check_name(const char *name, size_t index, size_t count,
char *out, size_t out_cap)
{
int rc;
if (name == NULL || out == NULL || out_cap == 0) {
return -1;
}
if (!valid_ident(name)) {
return -1;
}
if (count == 0 || index >= count) {
return -1;
}
if (count == 1) {
rc = snprintf(out, out_cap, "%s", name);
} else {
rc = snprintf(out, out_cap, "%s_%zu", name, index);
}
if (rc < 0 || (size_t)rc >= out_cap) {
return -1;
}
return 0;
}
struct st_error *
st_resolve_emit_feature(FILE *out, const struct st_resolve_feature *f)
{
bool direct;
size_t i;
struct st_error *e;
if (out == NULL || f == NULL) {
return st_error_usage("st_resolve_emit_feature: NULL argument");
}
if (!valid_ident(f->name)) {
return st_error_usage("st_resolve_emit_feature: feature name must "
"be a POSIX shell identifier");
}
if (f->check_count == 0) {
return st_error_usage("st_resolve_emit_feature: feature has no "
"checks");
}
if (f->check_names == NULL) {
return st_error_usage("st_resolve_emit_feature: NULL check_names");
}
for (i = 0; i < f->check_count; i++) {
if (!valid_ident(f->check_names[i])) {
return st_error_usage("st_resolve_emit_feature: check name "
"must be a POSIX shell identifier");
}
}
if (fprintf(out, "# feature '%s'\n", f->name) < 0) {
return st_error_io("I/O error emitting feature resolution");
}
/* The single check whose name IS the feature name was already set to
* have_<name> by todo 12's snippet — nothing to aggregate. */
direct = (f->check_count == 1 &&
strcmp(f->check_names[0], f->name) == 0);
if (!direct) {
if (f->check_count == 1) {
if (fprintf(out, "have_%s=$have_%s\n", f->name,
f->check_names[0]) < 0) {
return st_error_io("I/O error emitting feature resolution");
}
} else {
if (fputs("if ", out) == EOF) {
return st_error_io("I/O error emitting feature resolution");
}
for (i = 0; i < f->check_count; i++) {
if (i > 0 && fputs(" && ", out) == EOF) {
return st_error_io("I/O error emitting feature "
"resolution");
}
if (fprintf(out, "[ \"$have_%s\" = \"yes\" ]",
f->check_names[i]) < 0) {
return st_error_io("I/O error emitting feature "
"resolution");
}
}
if (fprintf(out, "; then\n have_%s=yes\nelse\n "
"have_%s=no\nfi\n", f->name, f->name) < 0) {
return st_error_io("I/O error emitting feature resolution");
}
}
}
if (f->guard != NULL) {
if (fputs("if ", out) == EOF) {
return st_error_io("I/O error emitting feature resolution");
}
e = st_when_emit(out, f->guard);
if (e != NULL) {
return e;
}
if (fprintf(out, "; then\n :\nelse\n have_%s=no\nfi\n",
f->name) < 0) {
return st_error_io("I/O error emitting feature resolution");
}
}
if (ferror(out)) {
return st_error_io("I/O error emitting feature resolution");
}
return NULL;
}
+192
View File
@@ -0,0 +1,192 @@
/*
* resolve.h - feature resolution + `when` guards (todo 14).
*
* Two jobs live here, both PURE CODE GENERATION — this module never runs
* a probe and never evaluates a guard itself; the emitted shell does that
* at CONFIGURE time inside the generated ./configure:
*
* (1) FEATURE RESOLUTION: combine each feature's per-check results (the
* have_<checkname>=yes|no variables set by todo 12's snippets) into
* the single availability boolean have_<feature>. A single-check
* feature names its check after the feature (have_<feature> is set
* directly by the probe); a multi-check feature derives distinct
* names (<feature>_0, <feature>_1, ...) and this module ANDs them.
*
* (2) THE `when` GUARD LANGUAGE: parse a guard STRING (the DSL's `when`
* property) into an opaque AST and emit a POSIX-sh boolean test that
* evaluates it against the configure-time variables $have_<feature>
* and the normalized $st_os.
*
* THE `when` DSL PLACEMENT (pinned here; extends the todo-9 grammar)
* ----------------------------------------------------------------------
* `when` is an OPTIONAL property on a `feature` node, spelling (a KDL
* `key=value` property, exactly like an option's `default=#bool`):
*
* feature "pthread" when="os=linux" { header "pthread.h" }
*
* Its value is a non-empty KDL string (enforced by src/kdl/schema.c). The
* guard STRING is NOT parsed by the schema — only resolve parses it (todo
* 16 extracts the string, calls st_when_parse_at with the feature node's
* span, then st_resolve_emit_feature). `target`/`option` take no `when`.
*
* GUARD GRAMMAR
* -------------
* expr := or_expr
* or_expr := and_expr ('or' and_expr)*
* and_expr := not_expr ('and' not_expr)*
* not_expr := 'not' not_expr | primary
* primary := '(' expr ')' | 'have_' <ident> | 'os=' <name>
*
* Tokens are whitespace-insensitive: `and`, `or`, `not`, `(`, `)`,
* `have_<ident>` (ident = [A-Za-z_][A-Za-z0-9_]*), and `os=<name>` (name
* uses the same identifier charset). `have_<ident>` reads the
* configure-time $have_<ident> (= "yes"|"no"); `os=<name>` compares the
* normalized $st_os for equality. Guards may reference OTHER features'
* availability (have_<bar>) — todo 16 orders features so referenced
* results are already computed; a self-referencing feature is a todo-16
* cycle to reject, not something resolve checks.
*
* THE $st_os CONTRACT (todo 16 preamble)
* --------------------------------------
* The configure preamble captures `uname -s` into $st_os, then calls the
* emitted st_os_norm() (see st_resolve_emit_os_norm), which rewrites it
* IN PLACE to the canonical name guards compare against:
*
* Linux -> linux; Darwin -> macos; FreeBSD -> bsd;
* OpenBSD/NetBSD -> bsd; anything else -> other
*
* The canonical table is the single source of truth: st_os_normalize()
* (C side) and the emitted st_os_norm() (shell side) both derive from it.
* os= guards therefore compare the ALREADY-normalized $st_os, so the
* emitted test is `[ "$st_os" = '<name>' ]` — no uname string is ever
* baked into a guard.
*
* EMITTED GUARD SHAPE (what todo 16 wraps)
* ----------------------------------------
* st_when_emit writes a SELF-CONTAINED boolean condition, designed to sit
* between `if` and `; then`:
*
* if <emitted>; then ... fi
*
* Leaves: [ "$have_<ident>" = "yes" ] [ "$st_os" = '<name>' ]
* NOT: ! ( <child> )
* AND/OR: ( <left> ) && ( <right> ) ( <left> ) || ( <right> )
*
* The subshell grouping `( ... )` is deliberate: POSIX sh gives `&&` and
* `||` EQUAL (left-associative) precedence, unlike C, so the AST's
* structure must be re-parenthesized on emission. Only `[`, `!`, `&&`,
* `||`, `(`, `)` are used — no `[[`, no `==`, no `local`; the output
* passes sh -n / bash -n / zsh -n. A NULL AST (empty/absent guard) is
* "always true": st_when_emit writes the no-op command `:`.
*
* FEATURE AGGREGATION (the todo-16 contract)
* ------------------------------------------
* st_resolve_emit_feature() emits, in order:
* 1. a `# feature 'NAME'` comment;
* 2. the have_<name> AND-aggregation — nothing for a single check whose
* name IS the feature name (the probe set it directly); a plain copy
* `have_<name>=$have_<c0>` for a single differently-named check; and
* an `if [ ... ] && [ ... ]; then have_<name>=yes; else
* have_<name>=no; fi` for multiple checks;
* 3. the `when` gate (when f->guard != NULL):
* `if <guard>; then :; else have_<name>=no; fi`.
* todo 16 emits the per-check probe snippets (todo 12) BEFORE calling
* this, so the aggregation always reads already-set have_<checkname>.
* Semantics pinned: have_<feature> = (AND of its checks) AND (guard).
*
* CHECK-NAME SCHEME
* -----------------
* st_resolve_check_name(name, index, count, ...) derives the per-check
* name todo 16 passes to st_probe_emit_snippet: count == 1 -> the feature
* name itself (have_<name> set directly); count > 1 -> "<name>_<index>".
*
* ERRORS
* ------
* st_when_parse/st_when_parse_at return an owned ST_ERR_KDL_SCHEMA error
* on a malformed guard, with a span heap-allocated IN THE SAME BLOCK as
* the error (the parser.c pattern). Without a base span (st_when_parse)
* the span points INTO the guard string (file = NULL, line/col = the
* offending token). With a base span (st_when_parse_at) the error carries
* that span — the `when` node's span, so todo 16 can attribute the error
* to the build file. Emitters follow the probe.c convention: NULL args /
* invalid identifiers -> ST_ERR_USAGE, quoting failure -> ST_ERR_INTERNAL,
* write failure -> ST_ERR_IO.
*
* Copyright (c) 2026 huntedbytheirs
* SPDX-License-Identifier: BSD-3-Clause
*/
#ifndef ST_DETECT_RESOLVE_H
#define ST_DETECT_RESOLVE_H
#include <stddef.h>
#include <stdio.h>
struct st_error;
struct st_span;
/* ---- the `when` guard AST (opaque) ------------------------------------ */
struct st_when_ast;
/* Parse a `when` guard string into an AST. NULL or empty guard -> *out is
* left NULL ("always true") and NULL is returned. On a malformed guard
* returns an owned ST_ERR_KDL_SCHEMA error (span points into the guard
* string, file = NULL). On catastrophic OOM the returned error may itself
* be NULL (codebase-wide convention). *out is untouched on error. */
struct st_error *st_when_parse(const char *guard, struct st_when_ast **out);
/* As st_when_parse, but on error the error carries `at` (borrowed) as its
* span instead of a guard-offset span. Use it when the guard came from a
* KDL node so the error points at the `when` property's span. `at` may be
* NULL (then behaves like st_when_parse). */
struct st_error *st_when_parse_at(const char *guard, const struct st_span *at,
struct st_when_ast **out);
/* Release an AST. NULL is a safe no-op. */
void st_when_free(struct st_when_ast *ast);
/* Emit the guard as a self-contained POSIX-sh boolean condition (see the
* header's EMITTED GUARD SHAPE). NULL AST -> emits `:` ("always true").
* Returns NULL on success, an owned error otherwise. */
struct st_error *st_when_emit(FILE *out, const struct st_when_ast *ast);
/* ---- the os mapping --------------------------------------------------- */
/* Map a raw `uname -s` string to its canonical name: Linux->linux,
* Darwin->macos, FreeBSD/OpenBSD/NetBSD->bsd, anything else (incl. NULL)
* -> other. The returned pointer is static. */
const char *st_os_normalize(const char *uname_s);
/* Emit the st_os_norm() shell function that rewrites $st_os in place to
* its canonical name (the same table as st_os_normalize). POSIX-sh only.
* Returns NULL on success. */
struct st_error *st_resolve_emit_os_norm(FILE *out);
/* ---- feature aggregation (the todo-16 contract) ----------------------- */
/* One feature's resolution model. Everything is borrowed: the strings and
* the guard AST are owned by the caller and must outlive the emit call
* (the emit does not free the AST). */
struct st_resolve_feature {
const char *name; /* feature name -> have_<name> */
const char *const *check_names;/* [check_count] have_<cn> variables */
size_t check_count; /* >= 1 */
struct st_when_ast *guard; /* parsed guard; NULL = no guard */
};
/* Emit one feature's resolution (aggregation + when-gate) as described in
* the header. Returns NULL on success, an owned error otherwise. */
struct st_error *st_resolve_emit_feature(FILE *out,
const struct st_resolve_feature *f);
/* Derive the checkname todo 16 passes to st_probe_emit_snippet for
* `name`'s check at 0-based `index` when the feature has `count` checks.
* count == 1 -> the feature name itself; count > 1 -> "<name>_<index>".
* Writes into `out` (capacity `out_cap`). Returns 0 on success, -1 on
* NULL args, an invalid feature name, a zero count, an out-of-range
* index, or truncation. */
int st_resolve_check_name(const char *name, size_t index, size_t count,
char *out, size_t out_cap);
#endif /* ST_DETECT_RESOLVE_H */
+42 -11
View File
@@ -548,12 +548,16 @@ validate_check(const struct st_kdl_node *c, enum st_check_kind kind,
return NULL; return NULL;
} }
/* feature "name" { <checks> }: the name shape (no properties) plus a /* feature "name" when="<expr>" { <checks> }: the name shape plus an
* children block of CHECK nodes. Each child's NAME is dispatched through * OPTIONAL `when` property (todo 14's guard placement, spelled
* the feature-check registry (src/detect/check_registry.h, todo 10) to * `feature "pthread" when="os=linux" { ... }` — a KDL key=value property,
* one of the 8 kinds - header, function, library, type, sizeof, * value = a non-empty unannotated string; the guard STRING is parsed by
* program, compiler_flag, pkg_config - and its argument/property shape * src/detect/resolve.c, not here) and a children block of CHECK nodes.
* is enforced from the registry table (see validate_check). */ * Each child's NAME is dispatched through the feature-check registry
* (src/detect/check_registry.h, todo 10) to one of the 8 kinds - header,
* function, library, type, sizeof, program, compiler_flag, pkg_config -
* and its argument/property shape is enforced from the registry table
* (see validate_check). Any property other than `when` is rejected. */
static struct st_error * static struct st_error *
validate_feature(const struct st_kdl_node *n) validate_feature(const struct st_kdl_node *n)
{ {
@@ -561,6 +565,8 @@ validate_feature(const struct st_kdl_node *n)
struct st_error *e; struct st_error *e;
char *name_str = NULL; char *name_str = NULL;
struct st_kdl_node *c; struct st_kdl_node *c;
struct st_kdl_prop *p;
bool seen_when = false;
e = one_name_arg(n, "feature", &name_str); e = one_name_arg(n, "feature", &name_str);
if (e != NULL) { if (e != NULL) {
@@ -568,16 +574,41 @@ validate_feature(const struct st_kdl_node *n)
} }
name_into(&n->args->value, name, sizeof name); name_into(&n->args->value, name, sizeof name);
if (n->props != NULL) { for (p = n->props; p != NULL; p = p->next) {
bool is_when = false;
char key[65]; char key[65];
char msg[192]; char msg[192];
name_into(&n->props->key, key, sizeof key); name_into(&p->key, key, sizeof key);
snprintf(msg, sizeof msg, "feature '%s' has unexpected property " e = name_equals(&p->key, "when", &is_when);
"'%s'", name, key); if (e != NULL) {
e = err_at_owned(n->props->key.span, msg);
goto done; goto done;
} }
if (!is_when) {
snprintf(msg, sizeof msg, "feature '%s' has unexpected "
"property '%s'", name, key);
e = err_at_owned(p->key.span, msg);
goto done;
}
if (seen_when) {
snprintf(msg, sizeof msg, "feature '%s' has a duplicate 'when' "
"property", name);
e = err_at_owned(p->key.span, msg);
goto done;
}
seen_when = true;
{
char cctx[192];
char *tmp = NULL;
snprintf(cctx, sizeof cctx, "feature '%s': 'when'", name);
e = require_string_arg(&p->value, p->annotation, cctx, &tmp);
free(tmp);
if (e != NULL) {
goto done;
}
}
}
for (c = n->children; c != NULL; c = c->next) { for (c = n->children; c != NULL; c = c->next) {
/* /*
+732
View File
@@ -0,0 +1,732 @@
/* LINK: ../../src/detect/resolve.c ../../src/gen/sh_emit.c ../../src/kdl/schema.c ../../src/detect/check_registry.c ../../src/kdl/value.c ../../src/kdl/parser.c ../../src/kdl/lexer.c ../../src/error.c ../../src/span.c */
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L /* mkdtemp, system/WEXITSTATUS */
#endif
/*
* tests/unit/test_resolve.c
*
* Unit tests for feature resolution + `when` guards (todo 14):
* src/detect/resolve.c. THE MODEL: guards are PARSED + EMITTED here, at
* stupidtools generation time, but they are EVALUATED at configure time
* inside the generated ./configure — against the shell variables
* $have_<feature> (set by todo 12's probe snippets) and the normalized
* $st_os. These tests prove the emitted guard shell is syntactically valid
* (sh/bash/zsh -n), genuinely evaluates (real `sh` execution against
* seeded variables), parses malformed guards into spanned errors, maps the
* os table both C-side and via the emitted shell, and that the schema
* accepts the `when` feature property (the todo-14 DSL placement).
*
* The magic LINK comment on line 1 is REQUIRED by tests/run.sh (extra .c
* sources, relative to tests/unit/). resolve.c needs sh_emit.c (st_sh_quote)
* + error.c + span.c; schema.c/check_registry.c/value.c/parser.c/lexer.c
* are linked for the schema `when` acceptance test (st_kdl_parse +
* st_kdl_validate).
*/
#include "munit.h"
#include "detect/resolve.h"
#include "error.h"
#include "kdl/ast.h"
#include "kdl/schema.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
/* ---- 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_resolve_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);
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);
}
/* 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;
}
/* Run "sh <path>" 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);
}
/* Run "sh <path>" capturing stdout into out_path; return exit status. */
static int
run_sh_out(const char *path, const char *out_path)
{
char cmd[1400];
int rc;
int n = snprintf(cmd, sizeof cmd, "sh '%s' > '%s' 2>/dev/null", path,
out_path);
if (n < 0 || (size_t)n >= sizeof cmd) {
return -1;
}
rc = system(cmd);
if (rc == -1) {
return -1;
}
return WEXITSTATUS(rc);
}
/* "<shell> -n <path>": 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);
}
/* ---- guard emission helpers ------------------------------------------- */
/* Parse + emit a guard into a static buffer; returns the emitted bytes. */
static const char *
emit_guard_text(const char *guard)
{
static char buf[1024];
FILE *f = tmpfile();
struct st_when_ast *ast = NULL;
size_t n;
munit_assert_not_null(f);
munit_assert_null(st_when_parse(guard, &ast));
munit_assert_not_null(ast);
munit_assert_null(st_when_emit(f, ast));
st_when_free(ast);
munit_assert_int(fflush(f), ==, 0);
munit_assert_int(fseek(f, 0, SEEK_SET), ==, 0);
n = fread(buf, 1, sizeof buf - 1, f);
fclose(f);
buf[n] = '\0';
return buf;
}
/* Write a script that evaluates `guard` under `assigns` (raw "var=value"
* lines, test-controlled constants) and echoes yes/no to stdout; capture
* and return the result ("yes" or "no"). */
static const char *
guard_eval(const char *guard, const char *const *assigns, size_t nassign)
{
char path[600];
char out[600];
FILE *f;
struct st_when_ast *ast = NULL;
size_t i;
int m;
m = mkpath(path, sizeof path, "guard.sh");
munit_assert_int(m, >, 0);
m = mkpath(out, sizeof out, "guard.out");
munit_assert_int(m, >, 0);
f = fopen(path, "w");
munit_assert_not_null(f);
(void)fprintf(f, "#!/bin/sh\n");
for (i = 0; i < nassign; i++) {
(void)fprintf(f, "%s\n", assigns[i]);
}
(void)fprintf(f, "if ");
munit_assert_null(st_when_parse(guard, &ast));
munit_assert_not_null(ast);
munit_assert_null(st_when_emit(f, ast));
st_when_free(ast);
(void)fprintf(f, "; then\n echo yes\nelse\n echo no\nfi\n");
fclose(f);
munit_assert_int(run_sh_out(path, out), ==, 0);
return read_result("guard.out");
}
/* Emit one feature's resolution under `assigns`, then dump have_<name>
* into feature.out; return that value. */
static const char *
feature_eval(const struct st_resolve_feature *f,
const char *const *assigns, size_t nassign)
{
char path[600];
char out[600];
FILE *fp;
size_t i;
int m;
m = mkpath(path, sizeof path, "feature.sh");
munit_assert_int(m, >, 0);
m = mkpath(out, sizeof out, "feature.out");
munit_assert_int(m, >, 0);
fp = fopen(path, "w");
munit_assert_not_null(fp);
(void)fprintf(fp, "#!/bin/sh\n");
for (i = 0; i < nassign; i++) {
(void)fprintf(fp, "%s\n", assigns[i]);
}
munit_assert_null(st_resolve_emit_feature(fp, f));
(void)fprintf(fp, "printf '%%s\\n' \"$have_%s\" > '%s'\n", f->name, out);
fclose(fp);
munit_assert_int(run_sh(path), ==, 0);
return read_result("feature.out");
}
/* Run the emitted st_os_norm() over `raw`, returning the normalized name
* the shell produced (the raw uname -s string is assigned to $st_os). */
static const char *
os_norm_shell(const char *raw)
{
char path[600];
char out[600];
FILE *f;
int m;
m = mkpath(path, sizeof path, "osnorm.sh");
munit_assert_int(m, >, 0);
m = mkpath(out, sizeof out, "osnorm.out");
munit_assert_int(m, >, 0);
f = fopen(path, "w");
munit_assert_not_null(f);
(void)fprintf(f, "#!/bin/sh\n");
munit_assert_null(st_resolve_emit_os_norm(f));
(void)fprintf(f, "st_os='%s'\nst_os_norm\nprintf '%%s\\n' \"$st_os\" > '%s'\n",
raw, out);
fclose(f);
munit_assert_int(run_sh(path), ==, 0);
return read_result("osnorm.out");
}
/* ---- (content) parse + emit ------------------------------------------- */
static MunitResult
test_parse_emit_content(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
struct st_when_ast *ast = NULL;
const char *s;
/* os=linux and have_pthread parses and emits the two leaf tests ANDed */
s = emit_guard_text("os=linux and have_pthread");
munit_assert_not_null(strstr(s, "\"$st_os\" = 'linux'"));
munit_assert_not_null(strstr(s, "\"$have_pthread\" = \"yes\""));
munit_assert_not_null(strstr(s, "&&"));
/* not os=macos: negated os test */
s = emit_guard_text("not os=macos");
munit_assert_not_null(strstr(s, "!"));
munit_assert_not_null(strstr(s, "\"$st_os\" = 'macos'"));
/* NULL / empty guard -> NULL ast (always true) */
munit_assert_null(st_when_parse(NULL, &ast));
munit_assert_null(ast);
munit_assert_null(st_when_parse("", &ast));
munit_assert_null(ast);
/* NULL out is a usage error, not a crash */
{
struct st_error *err = st_when_parse("os=linux", NULL);
munit_assert_not_null(err);
munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE);
st_error_free(err);
}
/* NULL stream is a usage error */
{
struct st_when_ast *a = NULL;
struct st_error *err;
munit_assert_null(st_when_parse("os=linux", &a));
munit_assert_not_null(a);
err = st_when_emit(NULL, a);
munit_assert_not_null(err);
munit_assert_int(st_error_category_of(err), ==, ST_ERR_USAGE);
st_error_free(err);
st_when_free(a);
}
return MUNIT_OK;
}
/* ---- (a) os=linux and have_pthread: real sh eval ---------------------- */
static MunitResult
test_guard_eval(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
static const char *const yes[] = { "have_pthread=yes", "st_os=linux" };
static const char *const no[] = { "have_pthread=no", "st_os=linux" };
munit_assert_string_equal(
guard_eval("os=linux and have_pthread", yes, 2), "yes");
munit_assert_string_equal(
guard_eval("os=linux and have_pthread", no, 2), "no");
return MUNIT_OK;
}
/* ---- (b) not os=macos: true on linux, false on macos ------------------ */
static MunitResult
test_not_os(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
static const char *const linux[] = { "st_os=linux" };
static const char *const macos[] = { "st_os=macos" };
munit_assert_string_equal(guard_eval("not os=macos", linux, 1), "yes");
munit_assert_string_equal(guard_eval("not os=macos", macos, 1), "no");
return MUNIT_OK;
}
/* ---- (c) precedence: have_a or (have_b and os=linux) ------------------ */
static MunitResult
test_precedence(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
static const char *const c1[] = { "have_a=yes", "have_b=no",
"st_os=macos" };
static const char *const c2[] = { "have_a=no", "have_b=yes",
"st_os=linux" };
static const char *const c3[] = { "have_a=no", "have_b=yes",
"st_os=macos" };
/* c1: have_a true -> true regardless of the right operand */
munit_assert_string_equal(
guard_eval("have_a or (have_b and os=linux)", c1, 3), "yes");
/* c2: have_a false, (have_b && os=linux) true -> true */
munit_assert_string_equal(
guard_eval("have_a or (have_b and os=linux)", c2, 3), "yes");
/* c3: have_a false and (have_b && os=linux) false -> false */
munit_assert_string_equal(
guard_eval("have_a or (have_b and os=linux)", c3, 3), "no");
return MUNIT_OK;
}
/* ---- (d) malformed guards -> spanned errors --------------------------- */
static void
assert_parse_error(const char *guard, const char *needle, size_t col)
{
struct st_when_ast *ast = NULL;
struct st_error *err = NULL;
err = st_when_parse(guard, &ast);
munit_assert_not_null(err);
munit_assert_null(ast);
munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA);
munit_assert_not_null(err->span);
munit_assert_size(err->span->line, ==, 1);
munit_assert_size(err->span->col, ==, col);
munit_assert_true(strstr(st_error_message(err), needle) != NULL);
st_error_free(err);
}
static MunitResult
test_parse_errors(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
/* the acceptance case: an unbalanced paren */
assert_parse_error("((x", "unknown token 'x'", 3);
/* empty feature name after have_ */
assert_parse_error("have_", "missing feature name", 1);
/* invalid feature identifier */
assert_parse_error("have_1x", "invalid feature name", 1);
/* empty os name */
assert_parse_error("os=", "missing os name", 1);
/* unknown word */
assert_parse_error("bogus", "unknown token 'bogus'", 1);
/* unterminated group */
assert_parse_error("(", "unexpected end", 2);
/* trailing close paren */
assert_parse_error("os=linux )", "unexpected token ')'", 10);
return MUNIT_OK;
}
/* ---- (e) os mapping: C-side table + emitted shell --------------------- */
static MunitResult
test_os_mapping(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
/* C-side mapping function */
munit_assert_string_equal(st_os_normalize("Linux"), "linux");
munit_assert_string_equal(st_os_normalize("Darwin"), "macos");
munit_assert_string_equal(st_os_normalize("FreeBSD"), "bsd");
munit_assert_string_equal(st_os_normalize("OpenBSD"), "bsd");
munit_assert_string_equal(st_os_normalize("NetBSD"), "bsd");
munit_assert_string_equal(st_os_normalize("Solaris"), "other");
munit_assert_string_equal(st_os_normalize("Linux-gnu"), "other");
munit_assert_string_equal(st_os_normalize(NULL), "other");
/* the emitted st_os_norm() reproduces the mapping under real sh */
munit_assert_string_equal(os_norm_shell("Linux"), "linux");
munit_assert_string_equal(os_norm_shell("Darwin"), "macos");
munit_assert_string_equal(os_norm_shell("FreeBSD"), "bsd");
munit_assert_string_equal(os_norm_shell("OpenBSD"), "bsd");
munit_assert_string_equal(os_norm_shell("Solaris"), "other");
return MUNIT_OK;
}
/* ---- (f) emitted guard shells pass sh -n / bash -n / zsh -n ----------- */
static MunitResult
test_syntax(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
static const char *const banned[] = {
"[[ ", "]]", "local ", "==", "<<<", "&>", "set -e",
};
static const char *const guards[] = {
"os=linux and have_pthread",
"not os=macos",
"have_a or (have_b and os=linux)",
"not (have_a and not os=bsd)",
"os=other",
};
char path[600];
FILE *f;
struct st_when_ast *ast = NULL;
size_t i;
int m;
char *bytes;
long n;
m = mkpath(path, sizeof path, "syntax.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_resolve_emit_os_norm(f));
for (i = 0; i < sizeof guards / sizeof guards[0]; i++) {
(void)fprintf(f, "if ");
munit_assert_null(st_when_parse(guards[i], &ast));
munit_assert_not_null(ast);
munit_assert_null(st_when_emit(f, ast));
st_when_free(ast);
(void)fprintf(f, "; then :; fi\n");
}
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);
return MUNIT_OK;
}
/* ---- feature aggregation: multi-check AND ----------------------------- */
static MunitResult
test_emit_feature_aggregate(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
static const char *const both[] = { "have_pthread_0=yes",
"have_pthread_1=yes" };
static const char *const one[] = { "have_pthread_0=yes",
"have_pthread_1=no" };
static const char *const none[] = { "have_pthread_0=no",
"have_pthread_1=no" };
const char *check_names[2] = { "pthread_0", "pthread_1" };
struct st_resolve_feature f = { "pthread", check_names, 2, NULL };
munit_assert_string_equal(feature_eval(&f, both, 2), "yes");
munit_assert_string_equal(feature_eval(&f, one, 2), "no");
munit_assert_string_equal(feature_eval(&f, none, 2), "no");
return MUNIT_OK;
}
/* ---- feature guard gating: when-guard forces have_<f>=no ------------- */
static MunitResult
test_emit_feature_guard(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
const char *check_names[1] = { "pthread" };
struct st_when_ast *ast = NULL;
struct st_resolve_feature f;
static const char *const linux[] = { "have_pthread=yes", "st_os=linux" };
static const char *const macos[] = { "have_pthread=yes", "st_os=macos" };
munit_assert_null(st_when_parse("os=linux", &ast));
f.name = "pthread";
f.check_names = check_names;
f.check_count = 1;
f.guard = ast;
/* guard holds -> the probe's have_pthread=yes survives */
munit_assert_string_equal(feature_eval(&f, linux, 2), "yes");
/* guard fails -> have_pthread is forced to no */
munit_assert_string_equal(feature_eval(&f, macos, 2), "no");
st_when_free(ast);
return MUNIT_OK;
}
/* ---- check-name scheme (the todo-12/16 naming contract) --------------- */
static MunitResult
test_check_name(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
char buf[64];
/* single-check feature -> the feature name itself */
munit_assert_int(st_resolve_check_name("pthread", 0, 1, buf,
sizeof buf), ==, 0);
munit_assert_string_equal(buf, "pthread");
/* multi-check feature -> <name>_0, <name>_1, ... */
munit_assert_int(st_resolve_check_name("pthread", 0, 2, buf,
sizeof buf), ==, 0);
munit_assert_string_equal(buf, "pthread_0");
munit_assert_int(st_resolve_check_name("pthread", 1, 2, buf,
sizeof buf), ==, 0);
munit_assert_string_equal(buf, "pthread_1");
/* invalid feature name / out-of-range index / zero count -> -1 */
munit_assert_int(st_resolve_check_name("1bad", 0, 1, buf,
sizeof buf), ==, -1);
munit_assert_int(st_resolve_check_name("pthread", 1, 1, buf,
sizeof buf), ==, -1);
munit_assert_int(st_resolve_check_name("pthread", 0, 0, buf,
sizeof buf), ==, -1);
return MUNIT_OK;
}
/* ---- schema: the `when` feature property (todo-14 placement) ---------- */
static MunitResult
test_schema_when(const MunitParameter params[], void *data)
{
(void)params;
(void)data;
struct st_error *err = NULL;
struct st_kdl_document *doc;
/* a feature may carry one `when "<expr>"` property */
doc = st_kdl_parse("project \"p\" version \"1.0\"\n"
"feature \"pthread\" when=\"os=linux\" "
"{ header \"pthread.h\" }",
"t.kdl", &err);
munit_assert_null(err);
munit_assert_not_null(doc);
munit_assert_null(st_kdl_validate(doc));
st_kdl_document_free(doc);
/* the guard string is NOT parsed by the schema (that is resolve's job):
* a malformed guard is still structurally a non-empty string */
doc = st_kdl_parse("project \"p\" version \"1.0\"\n"
"feature \"pthread\" when=\"((x\" "
"{ header \"pthread.h\" }",
"t.kdl", &err);
munit_assert_null(err);
munit_assert_not_null(doc);
munit_assert_null(st_kdl_validate(doc));
st_kdl_document_free(doc);
/* `when` must be a non-empty string */
doc = st_kdl_parse("project \"p\" version \"1.0\"\n"
"feature \"pthread\" when=42 "
"{ header \"pthread.h\" }",
"t.kdl", &err);
munit_assert_null(err);
munit_assert_not_null(doc);
err = st_kdl_validate(doc);
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), "non-empty string") !=
NULL);
st_error_free(err);
st_kdl_document_free(doc);
/* empty when string is rejected too */
doc = st_kdl_parse("project \"p\" version \"1.0\"\n"
"feature \"pthread\" when=\"\" "
"{ header \"pthread.h\" }",
"t.kdl", &err);
munit_assert_null(err);
munit_assert_not_null(doc);
err = st_kdl_validate(doc);
munit_assert_not_null(err);
munit_assert_true(strstr(st_error_message(err), "non-empty string") !=
NULL);
st_error_free(err);
st_kdl_document_free(doc);
/* duplicate `when` is rejected */
doc = st_kdl_parse("project \"p\" version \"1.0\"\n"
"feature \"pthread\" when=\"os=linux\" when=\"os=bsd\" "
"{ header \"pthread.h\" }",
"t.kdl", &err);
munit_assert_null(err);
munit_assert_not_null(doc);
err = st_kdl_validate(doc);
munit_assert_not_null(err);
munit_assert_true(strstr(st_error_message(err), "duplicate 'when'") !=
NULL);
st_error_free(err);
st_kdl_document_free(doc);
/* a non-when property on a feature is still rejected (unchanged) */
doc = st_kdl_parse("project \"p\" version \"1.0\"\n"
"feature \"f\" foo=1 { header \"h.h\" }",
"t.kdl", &err);
munit_assert_null(err);
munit_assert_not_null(doc);
err = st_kdl_validate(doc);
munit_assert_not_null(err);
munit_assert_true(strstr(st_error_message(err),
"unexpected property 'foo'") != NULL);
st_error_free(err);
st_kdl_document_free(doc);
return MUNIT_OK;
}
static MunitTest tests[] = {
{ "/resolve/parse-emit-content", test_parse_emit_content, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/guard-eval", test_guard_eval, setup, teardown,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/not-os", test_not_os, setup, teardown,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/precedence", test_precedence, setup, teardown,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/parse-errors", test_parse_errors, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/os-mapping", test_os_mapping, setup, teardown,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/syntax", test_syntax, setup, teardown,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/emit-feature-aggregate", test_emit_feature_aggregate, setup,
teardown, MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/emit-feature-guard", test_emit_feature_guard, setup, teardown,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/check-name", test_check_name, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/resolve/schema-when", test_schema_when, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL },
};
static const MunitSuite suite = {
"/resolve", 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);
}