feat(detect): define feature-check registry schema

This commit is contained in:
2026-08-28 22:09:45 -04:00
parent 219ea3fb4f
commit 00f4f0e1a0
5 changed files with 1079 additions and 44 deletions
+551
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---- 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" { <kw> <arg> }` (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);
}