diff --git a/src/detect/check_registry.c b/src/detect/check_registry.c new file mode 100644 index 0000000..21b5ffe --- /dev/null +++ b/src/detect/check_registry.c @@ -0,0 +1,251 @@ +/* + * check_registry.c - declarative feature-check kinds for stupidtools + * (todo 10). + * + * The 8 check kinds a `feature` node's children can carry, their DSL + * keyword spellings, and their argument/property shapes - as DATA (the + * st_check_shapes table), plus the node->kind mapping. See + * detect/check_registry.h for the semantics; this module deliberately + * does NOT generate probes (todo 11) and does NOT execute anything. + * + * Check node names may be any KDL string form (identifier, quoted, + * raw, multi-line): non-identifier names are interpreted through the + * value model (kdl/value.h) before comparison. A value-model failure on + * the name (bad escape etc.) is re-owned as a schema error, exactly + * like src/kdl/schema.c does. + * + * ERRORS AND SPANS + * ---------------- + * st_check_kind_from_node() reports failures as owned ST_ERR_KDL_SCHEMA + * errors whose span is heap-allocated IN THE SAME BLOCK as the error + * struct (the parser.c pattern), so err->span stays valid until + * st_error_free() and is never dangling. The span points at the node's + * NAME token (its `file` pointer borrows the caller's source buffer - + * the same lifetime contract as the parser). The single span-less error + * is the defensive NULL-node case: no source position exists to point + * at. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#include "detect/check_registry.h" + +#include "error.h" +#include "kdl/ast.h" +#include "kdl/value.h" + +#include +#include +#include +#include + +/* ---- the shape table (the data contract for todos 11/16) ------------- */ + +/* `library` and `pkg_config` carry an optional version constraint as two + * further positional args: the literal `version` keyword followed by the + * constraint string (see the header comment). */ +const struct st_check_shape st_check_shapes[ST_CHECK_KIND_COUNT] = { + { ST_CHECK_HEADER, "header", "header name", 1, 0 }, + { ST_CHECK_FUNCTION, "function", "function name", 1, 0 }, + { ST_CHECK_LIBRARY, "library", "library name (linked as -l)", + 1, 2 }, + { ST_CHECK_TYPE, "type", "type name", 1, 0 }, + { ST_CHECK_SIZEOF, "sizeof", "type name", 1, 0 }, + { ST_CHECK_PROGRAM, "program", "program name (checked via " + "command -v)", 1, 0 }, + { ST_CHECK_COMPILER_FLAG, "compiler_flag", "compiler flag", 1, 0 }, + { ST_CHECK_PKG_CONFIG, "pkg_config", "pkg-config package name", 1, + 2 }, +}; + +/* ---- owned errors ----------------------------------------------------- */ + +/* Build an owned ST_ERR_KDL_SCHEMA error whose span lives in the same + * allocation as the error struct (aligned right after it). Returns NULL + * only on allocation failure. */ +static struct st_error * +err_at_owned(struct st_span sp, const char *msg) +{ + const size_t align = _Alignof(struct st_span); + const size_t esize = + (sizeof(struct st_error) + align - 1) & ~(align - 1); + struct st_error *e; + struct st_span *spc; + size_t mlen; + + if (msg == NULL) { + msg = ""; + } + e = malloc(esize + sizeof(struct st_span)); + if (e == NULL) { + return NULL; + } + mlen = strlen(msg); + e->message = malloc(mlen + 1); + if (e->message == NULL) { + free(e); + return NULL; + } + memcpy(e->message, msg, mlen + 1); + e->category = ST_ERR_KDL_SCHEMA; + spc = (struct st_span *)((unsigned char *)e + esize); + *spc = sp; + e->span = spc; + return e; +} + +/* Re-own a value-model (ST_ERR_KDL_PARSE) error as a schema error: copy + * the message and the span VALUE into a fresh block. The caller frees + * the original error. */ +static struct st_error * +schema_err_from(const struct st_error *e) +{ + struct st_span sp; + + if (e->span != NULL) { + sp = *e->span; + } else { + sp = (struct st_span){ NULL, 0, 0 }; + } + return err_at_owned(sp, e->message != NULL ? e->message : ""); +} + +/* ---- name matching ---------------------------------------------------- */ + +/* Compare a node name token against a literal, accepting every string + * form (identifiers compared directly; other forms via the value + * model). Returns NULL and sets *out on success, or an owned schema + * error when the name is a string the value model cannot interpret. */ +static struct st_error * +ref_equals(const struct st_kdl_token_ref *name, const char *s, bool *out) +{ + if (name->kind == ST_TOK_IDENT) { + *out = name->len == strlen(s) && + memcmp(name->text, s, name->len) == 0; + return NULL; + } + { + struct st_kdl_value v; + struct st_error *e = st_kdl_value_from_token(name, &v); + + if (e != NULL) { + struct st_error *r = schema_err_from(e); + + st_error_free(e); + *out = false; + return r; + } + *out = v.kind == ST_KDL_VAL_STRING && strcmp(v.as.str, s) == 0; + st_kdl_value_free(&v); + return NULL; + } +} + +/* Copy a node name token into a fixed buffer for MESSAGE text, + * truncating to fit (message-only; never fatal). */ +static void +name_into(const struct st_kdl_token_ref *name, char *buf, size_t cap) +{ + if (cap == 0) { + return; + } + if (name->kind == ST_TOK_IDENT) { + size_t n = name->len < cap - 1 ? name->len : cap - 1; + + memcpy(buf, name->text, n); + buf[n] = '\0'; + return; + } + { + struct st_kdl_value v; + struct st_error *e = st_kdl_value_from_token(name, &v); + + if (e == NULL && v.kind == ST_KDL_VAL_STRING) { + snprintf(buf, cap, "%s", v.as.str); + } else { + snprintf(buf, cap, "?"); + } + st_error_free(e); + st_kdl_value_free(&v); + } +} + +/* ---- public API ------------------------------------------------------- */ + +enum st_check_kind +st_check_kind_from_node(const struct st_kdl_node *node, + struct st_error **err) +{ + char msg[192]; + size_t i; + + if (err != NULL) { + *err = NULL; + } + if (node == NULL) { + if (err != NULL) { + *err = st_error_kdl_schema("check node is NULL"); + } + return ST_CHECK_KIND_COUNT; + } + if (node->name.len == 0) { + if (err != NULL) { + *err = err_at_owned(node->name.span, + "check node has an empty name"); + } + return ST_CHECK_KIND_COUNT; + } + for (i = 0; i < ST_CHECK_KIND_COUNT; i++) { + bool eq = false; + struct st_error *e = ref_equals(&node->name, + st_check_shapes[i].name, &eq); + + if (e != NULL) { + if (err != NULL) { + *err = e; + } else { + st_error_free(e); + } + return ST_CHECK_KIND_COUNT; + } + if (eq) { + return st_check_shapes[i].kind; + } + } + if (err != NULL) { + char nm[65]; + + name_into(&node->name, nm, sizeof nm); + snprintf(msg, sizeof msg, + "unknown check kind '%s' (expected 'header', 'function', " + "'library', 'type', 'sizeof', 'program', 'compiler_flag', " + "or 'pkg_config')", nm); + *err = err_at_owned(node->name.span, msg); + } + return ST_CHECK_KIND_COUNT; +} + +const char * +st_check_kind_name(enum st_check_kind kind) +{ + if ((size_t)kind >= ST_CHECK_KIND_COUNT) { + return "?"; + } + return st_check_shapes[kind].name; +} + +const struct st_check_shape * +st_check_kind_shape(enum st_check_kind kind) +{ + if ((size_t)kind >= ST_CHECK_KIND_COUNT) { + return NULL; + } + return &st_check_shapes[kind]; +} + +size_t +st_check_kind_count(void) +{ + return ST_CHECK_KIND_COUNT; +} diff --git a/src/detect/check_registry.h b/src/detect/check_registry.h new file mode 100644 index 0000000..77eb4f9 --- /dev/null +++ b/src/detect/check_registry.h @@ -0,0 +1,140 @@ +/* + * check_registry.h - declarative feature-check kinds for stupidtools + * (todo 10). + * + * A `feature "name" { check* }` block's children are CHECK nodes. Each + * check node's NAME is one of eight fixed keywords - the check KINDS - + * and its FIRST (and only) positional argument is the TARGET the check + * is about: + * + * check := props* + * + * The eight kinds mirror GNU Autoconf's "Existing Tests" (manual ยง5) and + * pkg-config's PKG_CHECK_MODULES: + * + * header arg = header name -> #include probe + * e.g. header "pthread.h" + * function arg = function name -> link probe for the symbol + * e.g. function "strdup" + * library arg = library name -> -l link probe + * e.g. library "pthread" + * type arg = type name -> compile probe for the type + * e.g. type "size_t" + * sizeof arg = type name -> sizeof() probe + * e.g. sizeof "long" + * program arg = program name -> command -v probe + * e.g. program "pkg-config" + * compiler_flag arg = flag -> compile probe with added + * e.g. compiler_flag "-fsanitize=address" + * pkg_config arg = package name -> pkg-config --cflags --libs + * e.g. pkg_config "openssl" + * + * WHAT THIS TODO OWNS + * ------------------- + * Only the DECLARATIVE SHAPE: the kind enumeration, the kind<->keyword + * mapping, and per-kind argument/property shape as DATA (the table + * below). It deliberately does NOT implement probe generation (todo 11) + * or probe execution/caching (todo 12). src/kdl/schema.c consumes the + * mapping + table to enforce the DSL (see its todo-10 HOOK); todos 11 + * and 16 consume the same table to generate probes and substitute + * results - THIS HEADER IS THE CONTRACT for them. + * + * OPTIONAL `version` CONSTRAINT (library and pkg_config) + * ------------------------------------------------------ + * `library` and `pkg_config` may carry an optional version constraint, + * spelled EXACTLY like the project node's version (pinned by todo 9): + * the literal keyword `version` followed by a constraint string, as two + * further positional arguments: + * + * library "curl" version ">=7.0" + * pkg_config "openssl" version ">=1.1" + * + * The constraint is "": one of >= <= = > <, + * a dotted numeric version. Todo 10 pins only the SHAPE (the keyword + + * a non-empty unannotated string); the OPERATOR SEMANTICS are + * interpreted by todo 11 (probe generation) and todo 16 (result + * substitution). The spelling is an argument pair, not a KDL property, + * because this parser classifies `version "..."` as two positional + * arguments - the same reason the project node spells it that way. The + * property form `version="..."` is NOT part of the DSL and is rejected + * (see PROPERTIES below). + * + * PROPERTIES + * ---------- + * No check kind accepts properties in this todo (the grammar's `props*` + * is always empty for now); the schema validator rejects them with a + * spanned error. The shape table carries no props column for the same + * reason - todos 11/16 derive everything from the argument counts. + * + * ERRORS + * ------ + * st_check_kind_from_node() reports unknown/empty check names as owned + * ST_ERR_KDL_SCHEMA errors whose span is heap-allocated IN THE SAME + * BLOCK as the error (the parser.c pattern), so err->span stays valid + * until st_error_free(err). The span points at the node's name token. + * + * Copyright (c) 2026 huntedbytheirs + * SPDX-License-Identifier: BSD-3-Clause + */ + +#ifndef ST_DETECT_CHECK_REGISTRY_H +#define ST_DETECT_CHECK_REGISTRY_H + +#include + +struct st_error; +struct st_kdl_node; + +/* The eight declarative check kinds, in canonical order. */ +enum st_check_kind { + ST_CHECK_HEADER = 0, /* header "pthread.h" */ + ST_CHECK_FUNCTION, /* function "strdup" */ + ST_CHECK_LIBRARY, /* library "pthread" -> -lpthread */ + ST_CHECK_TYPE, /* type "size_t" */ + ST_CHECK_SIZEOF, /* sizeof "long" */ + ST_CHECK_PROGRAM, /* program "pkg-config" -> command -v */ + ST_CHECK_COMPILER_FLAG, /* compiler_flag "-fsanitize=address" */ + ST_CHECK_PKG_CONFIG, /* pkg_config "openssl" */ + ST_CHECK_KIND_COUNT, /* sentinel: number of real kinds above */ +}; + +/* The DSL shape of one check kind, as DATA (consumed by the schema + * validator and by todos 11/16). */ +struct st_check_shape { + enum st_check_kind kind; /* the kind this row describes */ + const char *name; /* DSL keyword spelling, e.g. "header" */ + const char *arg_meaning; /* what the required argument names, e.g. + "header name" */ + size_t required_args; /* positional args the node MUST carry */ + size_t optional_args; /* further positional args tolerated; 2 + for library/pkg_config = the literal + `version` keyword followed by the + version constraint string (see the + header comment); 0 elsewhere */ +}; + +/* The registry table: one row per kind, indexed by the ST_CHECK_* + * values (st_check_shapes[k].kind == k). */ +extern const struct st_check_shape st_check_shapes[ST_CHECK_KIND_COUNT]; + +/* Map a feature's check child node to its kind by the node NAME. The + * name may be any KDL string form (bare identifier or quoted). Returns + * the kind and leaves *err untouched on success. On failure returns + * ST_CHECK_KIND_COUNT and sets *err to an owned ST_ERR_KDL_SCHEMA error + * whose span points at the node's name: an unknown keyword, an empty + * name, or (defensively) a NULL node. */ +enum st_check_kind st_check_kind_from_node(const struct st_kdl_node *node, + struct st_error **err); + +/* Stable DSL keyword for a kind (e.g. "header"). Kinds outside the + * table yield "?" rather than indexing out of range. */ +const char *st_check_kind_name(enum st_check_kind kind); + +/* The shape-table row for a kind, or NULL when `kind` is not a real + * kind. */ +const struct st_check_shape *st_check_kind_shape(enum st_check_kind kind); + +/* Number of real kinds (ST_CHECK_KIND_COUNT). */ +size_t st_check_kind_count(void); + +#endif /* ST_DETECT_CHECK_REGISTRY_H */ diff --git a/src/kdl/schema.c b/src/kdl/schema.c index 9cef174..9da673d 100644 --- a/src/kdl/schema.c +++ b/src/kdl/schema.c @@ -27,6 +27,7 @@ #include "kdl/schema.h" +#include "detect/check_registry.h" #include "error.h" #include "kdl/ast.h" #include "kdl/value.h" @@ -438,16 +439,124 @@ done: return e; } +/* One check child of a feature: its kind has already been resolved by + * the registry (st_check_kind_from_node). Enforce the kind's DSL shape + * from the registry table - the required positional argument (a + * non-empty unannotated string), at most the kind's optional arguments + * (for library/pkg_config: the literal `version` keyword followed by a + * non-empty unannotated constraint string, mirroring the project + * node's spelling), no properties, and no children. */ +static struct st_error * +validate_check(const struct st_kdl_node *c, enum st_check_kind kind, + const char *feature) +{ + const struct st_check_shape *shape = st_check_kind_shape(kind); + char msg[384]; + char cname[65]; + char cctx[192]; + struct st_error *e; + struct st_kdl_arg *a; + size_t nargs = 1; + size_t max_args; + size_t i; + char *tmp = NULL; + + name_into(&c->name, cname, sizeof cname); + + if (c->args == NULL) { + snprintf(msg, sizeof msg, + "feature '%s': check '%s' requires an argument (the %s)", + feature, cname, shape->arg_meaning); + return err_at_owned(c->name.span, msg); + } + for (a = c->args->next; a != NULL; a = a->next) { + nargs++; + } + max_args = shape->required_args + shape->optional_args; + if (nargs > max_args) { + /* span the first argument beyond the kind's shape */ + a = c->args; + for (i = 0; i < max_args; i++) { + a = a->next; + } + if (shape->optional_args == 0) { + snprintf(msg, sizeof msg, + "feature '%s': check '%s' takes exactly one argument", + feature, cname); + } else { + snprintf(msg, sizeof msg, + "feature '%s': check '%s' takes at most three arguments " + "(the %s, then the optional 'version' keyword and its " + "constraint)", feature, cname, shape->arg_meaning); + } + return err_at_owned(a->value.span, msg); + } + snprintf(cctx, sizeof cctx, "feature '%s': check '%s' argument", + feature, cname); + e = require_string_arg(&c->args->value, c->args->annotation, cctx, + &tmp); + free(tmp); + if (e != NULL) { + return e; + } + if (shape->optional_args == 2) { + a = c->args->next; + if (a != NULL) { + if (a->annotation != NULL || + !(a->value.kind == ST_TOK_IDENT && a->value.len == 7 && + memcmp(a->value.text, "version", 7) == 0)) { + snprintf(msg, sizeof msg, + "feature '%s': check '%s': expected the keyword " + "'version' as the second argument", feature, cname); + return err_at_owned(a->value.span, msg); + } + if (a->next == NULL) { + snprintf(msg, sizeof msg, + "feature '%s': check '%s': 'version' requires the " + "version constraint argument", feature, cname); + return err_at_owned(a->value.span, msg); + } + { + char vctx[192]; + + snprintf(vctx, sizeof vctx, + "feature '%s': check '%s' version", feature, + cname); + e = require_string_arg(&a->next->value, + a->next->annotation, vctx, &tmp); + free(tmp); + if (e != NULL) { + return e; + } + } + } + } + if (c->props != NULL) { + char key[65]; + + name_into(&c->props->key, key, sizeof key); + snprintf(msg, sizeof msg, + "feature '%s': check '%s' has unexpected property '%s' " + "(checks take no properties)", feature, cname, key); + return err_at_owned(c->props->key.span, msg); + } + if (c->children != NULL) { + snprintf(msg, sizeof msg, + "feature '%s': check '%s' takes no children", feature, cname); + return err_at_owned(c->children->name.span, msg); + } + return NULL; +} + /* feature "name" { }: the name shape (no properties) plus a - * children block of CHECK nodes, validated STRUCTURALLY ONLY. The 8 - * check kinds and their exact argument/property shapes are todo 10's job; - * until its registry lands, every child is accepted provided it has at - * least one non-empty string argument and no children (its properties - * are deliberately left for todo 10 to validate). */ + * 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). */ static struct st_error * validate_feature(const struct st_kdl_node *n) { - char msg[192]; char name[65]; struct st_error *e; char *name_str = NULL; @@ -461,6 +570,7 @@ validate_feature(const struct st_kdl_node *n) if (n->props != NULL) { char key[65]; + char msg[192]; name_into(&n->props->key, key, sizeof key); snprintf(msg, sizeof msg, "feature '%s' has unexpected property " @@ -471,44 +581,22 @@ validate_feature(const struct st_kdl_node *n) for (c = n->children; c != NULL; c = c->next) { /* - * HOOK (todo 10): dispatch on the check node NAME through the + * HOOK (todo 10): dispatch the check node NAME through the * feature-check registry (src/detect/check_registry.h) to * validate the 8 kinds - header, function, library, type, * sizeof, program, compiler_flag, pkg_config - and their - * argument/property shapes. Until then, structural checks only: + * argument/property shapes. */ - if (c->args == NULL) { - char cname[65]; + enum st_check_kind kind; + struct st_error *ke = NULL; - name_into(&c->name, cname, sizeof cname); - snprintf(msg, sizeof msg, - "feature '%s': check '%s' requires an argument", name, - cname); - e = err_at_owned(c->name.span, msg); + kind = st_check_kind_from_node(c, &ke); + if (ke != NULL) { + e = ke; goto done; } - { - char cname[65]; - char cctx[192]; - char *tmp = NULL; - - name_into(&c->name, cname, sizeof cname); - snprintf(cctx, sizeof cctx, "feature '%s': check '%s' argument", - name, cname); - e = require_string_arg(&c->args->value, c->args->annotation, - cctx, &tmp); - free(tmp); - if (e != NULL) { - goto done; - } - } - if (c->children != NULL) { - char cname[65]; - - name_into(&c->name, cname, sizeof cname); - snprintf(msg, sizeof msg, - "feature '%s': check '%s' takes no children", name, cname); - e = err_at_owned(c->children->name.span, msg); + e = validate_check(c, kind, name); + if (e != NULL) { goto done; } } diff --git a/tests/unit/test_check_registry.c b/tests/unit/test_check_registry.c new file mode 100644 index 0000000..f867633 --- /dev/null +++ b/tests/unit/test_check_registry.c @@ -0,0 +1,551 @@ +/* LINK: ../../src/detect/check_registry.c ../../src/kdl/schema.c ../../src/kdl/parser.c ../../src/kdl/lexer.c ../../src/kdl/value.c ../../src/error.c ../../src/span.c */ +/* tests/unit/test_check_registry.c + * + * Unit tests for the declarative feature-check registry (todo 10). + * + * The magic LINK comment on line 1 is REQUIRED by tests/run.sh: it lists + * the extra .c sources to compile into this test binary (paths relative + * to tests/unit/, space-separated). check_registry.c needs error.c (it + * frees/re-owns value-model errors); schema.c needs value.c + error.c; + * parser.c + lexer.c build documents end-to-end from source text; + * span.c via error.c's st_span_print. error.c and span.c extend the + * todo-10 file list because both linked units reference them. + * + * The registry under test maps a feature's CHECK child node (its name = + * the check kind, its first arg = the target) onto the 8 declarative + * kinds pinned in src/detect/check_registry.h, and src/kdl/schema.c's + * HOOK enforces each kind's argument/property shape. Assertions check + * REAL error properties: category (ST_ERR_KDL_SCHEMA), message text + * (must name the offending node), and exact line/col spans. + */ +#include "munit.h" + +#include "detect/check_registry.h" +#include "error.h" +#include "kdl/ast.h" +#include "kdl/schema.h" + +#include +#include +#include + +/* ---- fixture loading -------------------------------------------------- */ + +/* Locate tests/fixtures/stupid.kdl. The harness runs the test binary with + * cwd = repo top (make check) or tests/unit (manual run); probe both. */ +static const char * +fixture_path(void) +{ + static const char *const candidates[] = { + "tests/fixtures/stupid.kdl", + "../fixtures/stupid.kdl", + }; + size_t i; + + for (i = 0; i < sizeof(candidates) / sizeof(candidates[0]); i++) { + FILE *f = fopen(candidates[i], "rb"); + if (f != NULL) { + fclose(f); + return candidates[i]; + } + } + return NULL; +} + +/* Slurp the fixture into a NUL-terminated buffer. Caller frees. */ +static char * +slurp_fixture(const char *path) +{ + FILE *f; + long n; + char *buf; + + if (path == NULL) { + return NULL; + } + f = fopen(path, "rb"); + if (f == NULL) { + return NULL; + } + if (fseek(f, 0, SEEK_END) != 0) { + fclose(f); + return NULL; + } + n = ftell(f); + if (n < 0 || fseek(f, 0, SEEK_SET) != 0) { + fclose(f); + return NULL; + } + buf = munit_malloc((size_t)n + 1); + if (fread(buf, 1, (size_t)n, f) != (size_t)n) { + free(buf); + fclose(f); + return NULL; + } + fclose(f); + buf[n] = '\0'; + return buf; +} + +/* ---- helpers ---------------------------------------------------------- */ + +/* Parse a single-node document and return its one node (parse is + * asserted to succeed). The document is leaked on purpose: tests free + * it via the returned node's owning doc... it is NOT: the DOCUMENT is + * returned alongside so the caller can free it. */ +static struct st_kdl_node * +parse_one_node(const char *src, struct st_kdl_document **doc_out) +{ + struct st_error *err = NULL; + + *doc_out = st_kdl_parse(src, "t.kdl", &err); + munit_assert_null(err); + munit_assert_not_null(*doc_out); + return (*doc_out)->nodes; +} + +/* Parse `src` and validate; assert a ST_ERR_KDL_SCHEMA error whose + * message contains `needle` and whose span is exactly (line, col). */ +static void +assert_schema_error(const char *src, const char *needle, size_t line, + size_t col) +{ + struct st_error *err = NULL; + struct st_kdl_document *doc = st_kdl_parse(src, "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_not_null(err->span); + munit_assert_size(err->span->line, ==, line); + munit_assert_size(err->span->col, ==, col); + munit_assert_true(strstr(st_error_message(err), needle) != NULL); + st_error_free(err); + st_kdl_document_free(doc); +} + +/* Parse `src` and validate; assert success. */ +static void +assert_valid(const char *src) +{ + struct st_error *err = NULL; + struct st_kdl_document *doc = st_kdl_parse(src, "t.kdl", &err); + + munit_assert_null(err); + munit_assert_not_null(doc); + err = st_kdl_validate(doc); + munit_assert_null(err); + st_kdl_document_free(doc); +} + +/* The 8 kinds in DSL order, each with a representative target argument. + * `arg_col` is the column of the target token in the single-line source + * `feature "f" { }` (col of kw + len(kw) + 1). */ +struct kind_case { + const char *kw; + enum st_check_kind kind; + const char *arg; + size_t arg_col; +}; + +static const struct kind_case kind_cases[] = { + { "header", ST_CHECK_HEADER, "pthread.h", 22 }, + { "function", ST_CHECK_FUNCTION, "strdup", 24 }, + { "library", ST_CHECK_LIBRARY, "pthread", 23 }, + { "type", ST_CHECK_TYPE, "size_t", 20 }, + { "sizeof", ST_CHECK_SIZEOF, "long", 22 }, + { "program", ST_CHECK_PROGRAM, "pkg-config", 23 }, + { "compiler_flag", ST_CHECK_COMPILER_FLAG, "-fsanitize=address", 29 }, + { "pkg_config", ST_CHECK_PKG_CONFIG, "openssl", 26 }, +}; + +/* ---- tests ------------------------------------------------------------ */ + +/* (a) all 8 kinds map from a representative DSL node; kind name and + * shape-table accessors round-trip; the table itself is coherent + * (COUNT entries, each self-indexed, exactly one required arg). */ +static MunitResult +test_all_kinds_map(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + size_t i; + + munit_assert_size(st_check_kind_count(), ==, 8); + munit_assert_size(sizeof(kind_cases) / sizeof(kind_cases[0]), ==, 8); + + for (i = 0; i < st_check_kind_count(); i++) { + char src[96]; + int n; + struct st_error *err = NULL; + struct st_kdl_document *doc = NULL; + struct st_kdl_node *node; + enum st_check_kind kind; + const struct st_check_shape *shape; + + /* a representative single-node document, e.g. header "pthread.h" */ + n = snprintf(src, sizeof src, "%s \"%s\"", kind_cases[i].kw, + kind_cases[i].arg); + munit_assert_int(n, >=, 0); + munit_assert_size((size_t)n, <, sizeof src); + node = parse_one_node(src, &doc); + munit_assert_not_null(node); + + err = NULL; + kind = st_check_kind_from_node(node, &err); + munit_assert_null(err); + munit_assert_int(kind, ==, kind_cases[i].kind); + st_kdl_document_free(doc); + + /* name round-trip: kind -> DSL keyword */ + munit_assert_string_equal(st_check_kind_name(kind_cases[i].kind), + kind_cases[i].kw); + + /* shape-table accessors agree with the kind */ + shape = st_check_kind_shape(kind_cases[i].kind); + munit_assert_not_null(shape); + munit_assert_int(shape->kind, ==, kind_cases[i].kind); + munit_assert_string_equal(shape->name, kind_cases[i].kw); + munit_assert_size(shape->required_args, ==, 1); + munit_assert_not_null(shape->arg_meaning); + munit_assert_true(shape->arg_meaning[0] != '\0'); + + /* only library and pkg_config take the optional `version` + * keyword-argument pair */ + if (kind_cases[i].kind == ST_CHECK_LIBRARY || + kind_cases[i].kind == ST_CHECK_PKG_CONFIG) { + munit_assert_size(shape->optional_args, ==, 2); + } else { + munit_assert_size(shape->optional_args, ==, 0); + } + } + + /* out-of-range accessors degrade safely */ + munit_assert_null(st_check_kind_shape(ST_CHECK_KIND_COUNT)); + munit_assert_string_equal(st_check_kind_name(ST_CHECK_KIND_COUNT), "?"); + return MUNIT_OK; +} + +/* (a) a quoted node name maps like a bare one (names are KDL strings in + * every string form), and each kind validates end-to-end inside a + * feature block. */ +static MunitResult +test_quoted_and_feature_forms(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + size_t i; + + for (i = 0; i < st_check_kind_count(); i++) { + char src[160]; + int n; + struct st_error *err = NULL; + struct st_kdl_document *doc = NULL; + struct st_kdl_node *node; + enum st_check_kind kind; + + /* quoted name form: "header" "pthread.h" */ + n = snprintf(src, sizeof src, "\"%s\" \"%s\"", kind_cases[i].kw, + kind_cases[i].arg); + munit_assert_int(n, >=, 0); + node = parse_one_node(src, &doc); + kind = st_check_kind_from_node(node, &err); + munit_assert_null(err); + munit_assert_int(kind, ==, kind_cases[i].kind); + st_kdl_document_free(doc); + + /* full feature form validates clean (the schema HOOK path) */ + n = snprintf(src, sizeof src, + "project \"p\" version \"1.0\"\n" + "feature \"f\" { %s \"%s\" }", + kind_cases[i].kw, kind_cases[i].arg); + munit_assert_int(n, >=, 0); + assert_valid(src); + } + return MUNIT_OK; +} + +/* (b) an unknown check kind errors with the node's name + span, both at + * the registry API and through schema validation; an empty node name and + * a NULL node also error. */ +static MunitResult +test_unknown_kind(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + struct st_error *err = NULL; + struct st_kdl_document *doc = NULL; + struct st_kdl_node *node; + enum st_check_kind kind; + + /* registry API: bare unknown name */ + node = parse_one_node("bogus \"x\"", &doc); + kind = st_check_kind_from_node(node, &err); + munit_assert_not_null(err); + munit_assert_int(kind, ==, ST_CHECK_KIND_COUNT); + 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, ==, 1); + munit_assert_true(strstr(st_error_message(err), "'bogus'") != NULL); + st_error_free(err); + st_kdl_document_free(doc); + + /* registry API: quoted unknown name */ + node = parse_one_node("\"wat\" \"x\"", &doc); + kind = st_check_kind_from_node(node, &err); + munit_assert_not_null(err); + munit_assert_int(kind, ==, ST_CHECK_KIND_COUNT); + munit_assert_true(strstr(st_error_message(err), "'wat'") != NULL); + st_error_free(err); + st_kdl_document_free(doc); + + /* schema end-to-end: the offending node + its span are named */ + assert_schema_error("project \"p\" version \"1.0\"\n" + "feature \"f\" { bogus \"x\" }", + "'bogus'", 2, 15); + + /* a hand-built node with an empty name token errors */ + { + struct st_kdl_node empty = { 0 }; + + kind = st_check_kind_from_node(&empty, &err); + munit_assert_not_null(err); + munit_assert_int(kind, ==, ST_CHECK_KIND_COUNT); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_not_null(err->span); + munit_assert_true(strstr(st_error_message(err), "empty name") + != NULL); + st_error_free(err); + } + + /* a NULL node errors without a span */ + kind = st_check_kind_from_node(NULL, &err); + munit_assert_not_null(err); + munit_assert_int(kind, ==, ST_CHECK_KIND_COUNT); + munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA); + munit_assert_null(err->span); + st_error_free(err); + return MUNIT_OK; +} + +/* (c) a check without its required argument errors with the check node's + * span; an empty-string target is equally rejected. */ +static MunitResult +test_missing_arg(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_schema_error("project \"p\" version \"1.0\"\n" + "feature \"f\" { header }", + "check 'header' requires an argument", 2, 15); + assert_schema_error("project \"p\" version \"1.0\"\n" + "feature \"f\" { library \"\" }", + "must be a non-empty string", 2, 23); + return MUNIT_OK; +} + +/* (d) the todo-9 fixture still validates clean end-to-end, and its + * feature children map to the expected kinds through the registry. */ +static MunitResult +test_fixture_valid(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + const char *path = fixture_path(); + char *src; + struct st_error *err = NULL; + struct st_kdl_document *doc; + struct st_kdl_node *n; + enum st_check_kind expect[2] = { ST_CHECK_HEADER, ST_CHECK_LIBRARY }; + + munit_assert_not_null(path); + src = slurp_fixture(path); + munit_assert_not_null(src); + + doc = st_kdl_parse(src, path, &err); + munit_assert_not_null(doc); + munit_assert_null(err); + munit_assert_null(st_kdl_validate(doc)); + + for (n = doc->nodes; n != NULL; n = n->next) { + if (n->name.kind == ST_TOK_IDENT && n->name.len == 7 && + memcmp(n->name.text, "feature", 7) == 0) { + struct st_kdl_node *c; + size_t ci = 0; + + for (c = n->children; c != NULL; c = c->next, ci++) { + enum st_check_kind kind; + + if (ci == 0) { + /* first child: `header` in pthread, `library` in math */ + if (c->name.kind == ST_TOK_IDENT && + c->name.len == 6 && + memcmp(c->name.text, "header", 6) == 0) { + kind = st_check_kind_from_node(c, &err); + munit_assert_null(err); + munit_assert_int(kind, ==, ST_CHECK_HEADER); + } else { + kind = st_check_kind_from_node(c, &err); + munit_assert_null(err); + munit_assert_int(kind, ==, ST_CHECK_LIBRARY); + } + } else { + /* second child exists only in feature "pthread" */ + munit_assert_size(ci, ==, 1); + kind = st_check_kind_from_node(c, &err); + munit_assert_null(err); + munit_assert_int(kind, ==, expect[ci]); + } + } + } + } + + st_kdl_document_free(doc); + free(src); + return MUNIT_OK; +} + +/* (e) required-arg enforcement per kind: each of the 8 kinds validates + * with its target argument, and fails - naming the check node - without + * it, with a wrong-typed target, with a second argument, or with a + * children block. */ +static MunitResult +test_required_arg_per_kind(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + size_t i; + + for (i = 0; i < st_check_kind_count(); i++) { + char src[192]; + char needle[128]; + int n; + + /* missing target: span = the check name (col 15) */ + n = snprintf(src, sizeof src, + "project \"p\" version \"1.0\"\nfeature \"f\" { %s }", + kind_cases[i].kw); + munit_assert_int(n, >=, 0); + n = snprintf(needle, sizeof needle, "check '%s' requires an argument", + kind_cases[i].kw); + munit_assert_int(n, >=, 0); + assert_schema_error(src, needle, 2, 15); + + /* wrong-typed target: number where a string belongs */ + n = snprintf(src, sizeof src, + "project \"p\" version \"1.0\"\nfeature \"f\" { %s 42 }", + kind_cases[i].kw); + munit_assert_int(n, >=, 0); + n = snprintf(needle, sizeof needle, + "check '%s' argument must be a non-empty string (got " + "int)", kind_cases[i].kw); + munit_assert_int(n, >=, 0); + assert_schema_error(src, needle, 2, kind_cases[i].arg_col); + + /* a second positional argument is rejected; for library and + * pkg_config it must be the literal `version` keyword */ + n = snprintf(src, sizeof src, + "project \"p\" version \"1.0\"\n" + "feature \"f\" { %s \"x\" \"y\" }", kind_cases[i].kw); + munit_assert_int(n, >=, 0); + if (kind_cases[i].kind == ST_CHECK_LIBRARY || + kind_cases[i].kind == ST_CHECK_PKG_CONFIG) { + n = snprintf(needle, sizeof needle, + "check '%s': expected the keyword 'version'", + kind_cases[i].kw); + } else { + n = snprintf(needle, sizeof needle, + "check '%s' takes exactly one argument", + kind_cases[i].kw); + } + munit_assert_int(n, >=, 0); + assert_schema_error(src, needle, 2, kind_cases[i].arg_col + 4); + + /* a children block is rejected (child token at arg_col + 6) */ + n = snprintf(src, sizeof src, + "project \"p\" version \"1.0\"\n" + "feature \"f\" { %s \"x\" { a } }", kind_cases[i].kw); + munit_assert_int(n, >=, 0); + n = snprintf(needle, sizeof needle, "check '%s' takes no children", + kind_cases[i].kw); + munit_assert_int(n, >=, 0); + assert_schema_error(src, needle, 2, kind_cases[i].arg_col + 6); + } + return MUNIT_OK; +} + +/* the optional `version` constraint on library/pkg_config: the literal + * `version` keyword followed by a non-empty unannotated string (spelled + * like the project node's version, todo 9's pinned decision). Other + * kinds reject any further argument, and NO check takes properties. */ +static MunitResult +test_version_constraint(const MunitParameter params[], void *data) +{ + (void)params; + (void)data; + + assert_valid("project \"p\" version \"1.0\"\n" + "feature \"f\" { library \"curl\" version \">=7.0\" }"); + assert_valid("project \"p\" version \"1.0\"\n" + "feature \"f\" { pkg_config \"openssl\" version " + "\">=1.1\" }"); + + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { library \"l\" " + "version }", + "'version' requires the version constraint argument", 2, 27); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { library \"l\" " + "version 42 }", + "version must be a non-empty string (got int)", 2, 35); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { library \"l\" " + "\"x\" }", + "expected the keyword 'version'", 2, 27); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { library \"l\" " + "version \"1\" \"x\" }", + "takes at most three arguments", 2, 39); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { header \"h.h\" " + "version \">=1\" }", + "takes exactly one argument", 2, 28); + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { library \"l\" " + "version=\">=1\" }", + "has unexpected property 'version' (checks take no properties)", + 2, 27); + return MUNIT_OK; +} + +static MunitTest tests[] = { + { "/registry/all-kinds-map", test_all_kinds_map, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/registry/quoted-and-feature-forms", test_quoted_and_feature_forms, + NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/registry/unknown-kind", test_unknown_kind, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/registry/missing-arg", test_missing_arg, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/registry/fixture-valid", test_fixture_valid, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { "/registry/required-arg-per-kind", test_required_arg_per_kind, NULL, + NULL, MUNIT_TEST_OPTION_NONE, NULL }, + { "/registry/version-constraint", test_version_constraint, NULL, NULL, + MUNIT_TEST_OPTION_NONE, NULL }, + { NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL }, +}; + +static const MunitSuite suite = { + "/registry", 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); +} diff --git a/tests/unit/test_schema.c b/tests/unit/test_schema.c index c0bfb68..12e9e0c 100644 --- a/tests/unit/test_schema.c +++ b/tests/unit/test_schema.c @@ -1,4 +1,4 @@ -/* LINK: ../../src/kdl/schema.c ../../src/kdl/value.c ../../src/kdl/parser.c ../../src/kdl/lexer.c ../../src/error.c ../../src/span.c */ +/* LINK: ../../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 */ /* tests/unit/test_schema.c * * Unit tests for the stupidtools DSL schema validator (todo 9). @@ -396,24 +396,29 @@ test_target_children(const MunitParameter params[], void *data) return MUNIT_OK; } -/* feature children: structural validation only (the 8 check kinds are - * todo 10). Any check name passes provided it has >= 1 non-empty string - * argument and no children; check-kind semantics are out of scope here. */ +/* feature children: each check's NAME must be one of the 8 kinds from + * the feature-check registry (todo 10), and the kind's argument shape is + * enforced (exactly one non-empty string target, no children; the + * optional `version` property is todo 10's too - see + * test_check_registry.c for the per-kind table). */ static MunitResult test_feature_checks_structural(const MunitParameter params[], void *data) { (void)params; (void)data; - /* the two fixture kinds plus arbitrary names all pass structurally */ + /* the two fixture kinds plus the rest of the 8 kinds pass */ assert_valid("project \"p\" version \"1.0\"\n" "feature \"f\" { header \"h.h\"\nlibrary \"l\" }"); assert_valid("project \"p\" version \"1.0\"\n" "feature \"f\" { type \"size_t\"\nsizeof \"int\"\n" "function \"strdup\"\nprogram \"gcc\"\n" "compiler_flag \"-Wall\"\npkg_config \"zlib\" }"); - assert_valid("project \"p\" version \"1.0\"\n" - "feature \"f\" { frobnicate \"x\" }"); + + /* unknown check kinds are rejected (registry dispatch, todo 10) */ + assert_schema_error( + "project \"p\" version \"1.0\"\nfeature \"f\" { frobnicate \"x\" }", + "'frobnicate'", 2, 15); /* but a check still needs its argument (a non-empty string) */ assert_schema_error(