diff --git a/src/detect/resolve.c b/src/detect/resolve.c new file mode 100644 index 0000000..5317354 --- /dev/null +++ b/src/detect/resolve.c @@ -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_" = "yes" ]` and + * `[ "$st_os" = '' ]`; 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 +#include +#include +#include + +/* ---- 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_: $have_ = "yes" */ + ST_WHEN_OS, /* os=: $st_os = '' */ + ST_WHEN_NOT, /* ! */ + ST_WHEN_AND, /* && */ + ST_WHEN_OR, /* || */ +}; + +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=` 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_ 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; +} diff --git a/src/detect/resolve.h b/src/detect/resolve.h new file mode 100644 index 0000000..321bc13 --- /dev/null +++ b/src/detect/resolve.h @@ -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_=yes|no variables set by todo 12's snippets) into + * the single availability boolean have_. A single-check + * feature names its check after the feature (have_ is set + * directly by the probe); a multi-check feature derives distinct + * names (_0, _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_ + * 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_' | 'os=' + * + * Tokens are whitespace-insensitive: `and`, `or`, `not`, `(`, `)`, + * `have_` (ident = [A-Za-z_][A-Za-z0-9_]*), and `os=` (name + * uses the same identifier charset). `have_` reads the + * configure-time $have_ (= "yes"|"no"); `os=` compares the + * normalized $st_os for equality. Guards may reference OTHER features' + * availability (have_) — 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" = '' ]` — 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 ; then ... fi + * + * Leaves: [ "$have_" = "yes" ] [ "$st_os" = '' ] + * NOT: ! ( ) + * AND/OR: ( ) && ( ) ( ) || ( ) + * + * 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_ AND-aggregation — nothing for a single check whose + * name IS the feature name (the probe set it directly); a plain copy + * `have_=$have_` for a single differently-named check; and + * an `if [ ... ] && [ ... ]; then have_=yes; else + * have_=no; fi` for multiple checks; + * 3. the `when` gate (when f->guard != NULL): + * `if ; then :; else have_=no; fi`. + * todo 16 emits the per-check probe snippets (todo 12) BEFORE calling + * this, so the aggregation always reads already-set have_. + * Semantics pinned: have_ = (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_ set directly); count > 1 -> "_". + * + * 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 +#include + +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_ */ + const char *const *check_names;/* [check_count] have_ 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 -> "_". + * 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 */ diff --git a/src/kdl/schema.c b/src/kdl/schema.c index 9da673d..88d6201 100644 --- a/src/kdl/schema.c +++ b/src/kdl/schema.c @@ -548,12 +548,16 @@ validate_check(const struct st_kdl_node *c, enum st_check_kind kind, return NULL; } -/* feature "name" { }: the name shape (no properties) plus a - * children block of CHECK nodes. 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). */ +/* feature "name" when="" { }: the name shape plus an + * OPTIONAL `when` property (todo 14's guard placement, spelled + * `feature "pthread" when="os=linux" { ... }` — a KDL key=value property, + * value = a non-empty unannotated string; the guard STRING is parsed by + * src/detect/resolve.c, not here) and a children block of CHECK nodes. + * 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 * validate_feature(const struct st_kdl_node *n) { @@ -561,6 +565,8 @@ validate_feature(const struct st_kdl_node *n) struct st_error *e; char *name_str = NULL; struct st_kdl_node *c; + struct st_kdl_prop *p; + bool seen_when = false; e = one_name_arg(n, "feature", &name_str); if (e != NULL) { @@ -568,15 +574,40 @@ validate_feature(const struct st_kdl_node *n) } 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 msg[192]; - name_into(&n->props->key, key, sizeof key); - snprintf(msg, sizeof msg, "feature '%s' has unexpected property " - "'%s'", name, key); - e = err_at_owned(n->props->key.span, msg); - goto done; + name_into(&p->key, key, sizeof key); + e = name_equals(&p->key, "when", &is_when); + if (e != NULL) { + 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) { diff --git a/tests/unit/test_resolve.c b/tests/unit/test_resolve.c new file mode 100644 index 0000000..faf5072 --- /dev/null +++ b/tests/unit/test_resolve.c @@ -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_ (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 +#include +#include +#include +#include + +/* ---- per-test temp dir ------------------------------------------------- */ + +static char temp_dir[128]; + +static void * +setup(const MunitParameter params[], void *user_data) +{ + (void)params; + (void)user_data; + int n = snprintf(temp_dir, sizeof temp_dir, "/tmp/st_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 " 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 " 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); +} + +/* " -n ": return the shell's exit status or -1. */ +static int +syntax_check(const char *shell, const char *path) +{ + char cmd[700]; + int rc; + int n = snprintf(cmd, sizeof cmd, "%s -n '%s'", shell, path); + + if (n < 0 || (size_t)n >= sizeof cmd) { + return -1; + } + rc = system(cmd); + if (rc == -1) { + return -1; + } + return WEXITSTATUS(rc); +} + +/* ---- 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_ + * 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_=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 -> _0, _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 ""` 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); +}