/* * schema.c - stupidtools DSL schema validation (todo 9). * * Validates a parsed KDL document (src/kdl/ast.h) against the stupidtools * DSL. The grammar is pinned in schema.h - the contract for todos 10/16/23. * This module only READS the AST; it never modifies or frees it. * * Type checks go through the value model (src/kdl/value.h): names, files, * version strings and check arguments must be non-empty unannotated * strings; an option's `default` must be an unannotated boolean. Value- * model failures (bad escapes, overflow) are re-owned as schema errors. * * ERRORS AND SPANS * ---------------- * Every failure returns an owned st_error of category ST_ERR_KDL_SCHEMA * 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 is borrowed-by-value from the AST's * token refs, whose `file` pointer in turn borrows the caller's source * buffer - the same lifetime contract as the parser. The single span-less * error is "missing project" on an empty document: there is no source * position (or file name) to point at. * * Copyright (c) 2026 huntedbytheirs * SPDX-License-Identifier: BSD-3-Clause */ #include "kdl/schema.h" #include "detect/check_registry.h" #include "error.h" #include "kdl/ast.h" #include "kdl/value.h" #include #include #include #include #include /* ---- owned errors ----------------------------------------------------- */ /* Build an owned ST_ERR_KDL_SCHEMA error whose span lives in the same * allocation as the error struct (aligned right after it). Returns NULL * only on allocation failure. */ static struct st_error * err_at_owned(struct st_span sp, const char *msg) { const size_t align = _Alignof(struct st_span); const size_t esize = (sizeof(struct st_error) + align - 1) & ~(align - 1); struct st_error *e; struct st_span *spc; size_t mlen; if (msg == NULL) { msg = ""; } e = malloc(esize + sizeof(struct st_span)); if (e == NULL) { return NULL; } mlen = strlen(msg); e->message = malloc(mlen + 1); if (e->message == NULL) { free(e); return NULL; } memcpy(e->message, msg, mlen + 1); e->category = ST_ERR_KDL_SCHEMA; spc = (struct st_span *)((unsigned char *)e + esize); *spc = sp; e->span = spc; return e; } /* Re-own a value-model (ST_ERR_KDL_PARSE) error as a schema error: copy * the message and the span VALUE into a fresh block. The caller frees the * original error. */ static struct st_error * schema_err_from(const struct st_error *e) { struct st_span sp; if (e->span != NULL) { sp = *e->span; } else { sp = (struct st_span){ NULL, 0, 0 }; } return err_at_owned(sp, e->message != NULL ? e->message : ""); } /* Interpret a token ref through the value model, converting any failure * into an owned schema error (the original is freed here). On success * `*out` holds the interpreted value (caller frees via * st_kdl_value_free). */ static struct st_error * value_interpret(const struct st_kdl_token_ref *tok, const struct st_kdl_token_ref *ann, struct st_kdl_value *out) { struct st_error *e = st_kdl_value_from_token_annotated(tok, ann, out); if (e != NULL) { struct st_error *r = schema_err_from(e); st_error_free(e); return r; } return NULL; } /* ---- naming helpers --------------------------------------------------- */ /* Copy a node name / property key into a fixed buffer for MESSAGE text, * truncating to fit. Identifiers are copied raw (the common case, no * allocation); other string forms are interpreted via the value model, * falling back to "?" when that fails (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); } } /* Compare a node name against a literal, accepting every string form * (identifiers compared directly; other forms via the value model). * Sets *out and returns NULL on success, or an owned schema error when * the name is a string the value model cannot interpret. */ static struct st_error * name_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; } } /* ---- argument helpers ------------------------------------------------- */ /* Interpret `tok` (with optional annotation) as the non-empty unannotated * string a DSL slot requires; on success *out owns the string (caller * frees). `ctx` names the slot in messages, e.g. "target name". */ static struct st_error * require_string_arg(const struct st_kdl_token_ref *tok, const struct st_kdl_token_ref *ann, const char *ctx, char **out) { struct st_kdl_value v; struct st_error *e; char msg[192]; *out = NULL; e = value_interpret(tok, ann, &v); if (e != NULL) { return e; } if (v.annotation != NULL) { snprintf(msg, sizeof msg, "%s must not carry a type annotation", ctx); e = err_at_owned(tok->span, msg); st_kdl_value_free(&v); return e; } if (v.kind != ST_KDL_VAL_STRING) { snprintf(msg, sizeof msg, "%s must be a non-empty string (got %s)", ctx, st_kdl_value_kind_name(v.kind)); e = err_at_owned(tok->span, msg); st_kdl_value_free(&v); return e; } if (v.as.str[0] == '\0') { snprintf(msg, sizeof msg, "%s must be a non-empty string", ctx); e = err_at_owned(tok->span, msg); st_kdl_value_free(&v); return e; } *out = v.as.str; v.as.str = NULL; st_kdl_value_free(&v); /* frees the annotation only */ return NULL; } /* The shared name shape of target/feature/option: exactly one non-empty * string argument. `ctx` is the node kind ("target"). On success *out * owns the name (caller frees). */ static struct st_error * one_name_arg(const struct st_kdl_node *n, const char *ctx, char **out) { char msg[192]; char cctx[96]; *out = NULL; if (n->args == NULL) { snprintf(msg, sizeof msg, "%s requires a name argument", ctx); return err_at_owned(n->name.span, msg); } if (n->args->next != NULL) { char nm[65]; name_into(&n->args->value, nm, sizeof nm); snprintf(msg, sizeof msg, "%s '%s' takes exactly one argument", ctx, nm); return err_at_owned(n->args->next->value.span, msg); } snprintf(cctx, sizeof cctx, "%s name", ctx); return require_string_arg(&n->args->value, n->args->annotation, cctx, out); } /* ---- per-node validators ---------------------------------------------- */ /* project "name" version "semver": the name is the first positional * argument; the version string follows the LITERAL `version` keyword. * No properties, no children, exactly three arguments. */ static struct st_error * validate_project(const struct st_kdl_node *n) { struct st_kdl_arg *a = n->args; struct st_kdl_arg *ver_kw; char msg[192]; char name[65]; char ctx[96]; struct st_error *e; char *name_str = NULL; char *ver_str = NULL; if (a == NULL) { return err_at_owned(n->name.span, "project requires a name argument"); } e = require_string_arg(&a->value, a->annotation, "project name", &name_str); if (e != NULL) { return e; } name_into(&a->value, name, sizeof name); a = a->next; if (a == NULL) { snprintf(msg, sizeof msg, "project '%s' requires a version (expected: project \"name\" " "version \"semver\")", name); e = err_at_owned(n->name.span, msg); goto done; } 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, "project '%s': expected the keyword 'version' as the second " "argument", name); e = err_at_owned(a->value.span, msg); goto done; } ver_kw = a; a = a->next; if (a == NULL) { snprintf(msg, sizeof msg, "project '%s': 'version' requires the version string argument", name); e = err_at_owned(ver_kw->value.span, msg); goto done; } snprintf(ctx, sizeof ctx, "project '%s' version", name); e = require_string_arg(&a->value, a->annotation, ctx, &ver_str); if (e != NULL) { goto done; } if (a->next != NULL) { snprintf(msg, sizeof msg, "project '%s' takes exactly three arguments: the name, the " "'version' keyword, and the version string", name); e = err_at_owned(a->next->value.span, msg); goto done; } if (n->props != NULL) { char key[65]; name_into(&n->props->key, key, sizeof key); snprintf(msg, sizeof msg, "project '%s' has unexpected property " "'%s'", name, key); e = err_at_owned(n->props->key.span, msg); goto done; } if (n->children != NULL) { snprintf(msg, sizeof msg, "project '%s' takes no children", name); e = err_at_owned(n->children->name.span, msg); goto done; } e = NULL; done: free(name_str); free(ver_str); return e; } /* One `src ""` or `feature ""` child of a target: exactly one * non-empty string argument, no properties, no children. */ static struct st_error * validate_target_child(const struct st_kdl_node *c, const char *ctx, bool is_src) { const char *kind = is_src ? "src" : "feature"; const char *slot = is_src ? "file" : "name"; char cctx[192]; char msg[192]; if (c->args == NULL) { snprintf(msg, sizeof msg, "%s '%s' requires a %s argument", ctx, kind, slot); return err_at_owned(c->name.span, msg); } if (c->args->next != NULL) { snprintf(msg, sizeof msg, "%s '%s' takes exactly one argument", ctx, kind); return err_at_owned(c->args->next->value.span, msg); } if (c->props != NULL) { snprintf(msg, sizeof msg, "%s '%s' takes no properties", ctx, kind); return err_at_owned(c->props->key.span, msg); } if (c->children != NULL) { snprintf(msg, sizeof msg, "%s '%s' takes no children", ctx, kind); return err_at_owned(c->children->name.span, msg); } { char *tmp = NULL; struct st_error *e; snprintf(cctx, sizeof cctx, "%s '%s' %s", ctx, kind, slot); e = require_string_arg(&c->args->value, c->args->annotation, cctx, &tmp); free(tmp); return e; } } /* target "name" { src ...; feature ...; }: the name shape plus a children * block whose nodes are only `src` (a file) or `feature` (a reference to * a top-level feature by name). Presence-checking the reference against * the document is OPTIONAL for this todo - only the shape is enforced. */ static struct st_error * validate_target(const struct st_kdl_node *n) { char msg[256]; char name[65]; char ctx[96]; struct st_error *e; char *name_str = NULL; struct st_kdl_node *c; e = one_name_arg(n, "target", &name_str); if (e != NULL) { return e; } name_into(&n->args->value, name, sizeof name); if (n->props != NULL) { char key[65]; name_into(&n->props->key, key, sizeof key); snprintf(msg, sizeof msg, "target '%s' has unexpected property " "'%s'", name, key); e = err_at_owned(n->props->key.span, msg); goto done; } snprintf(ctx, sizeof ctx, "target '%s'", name); for (c = n->children; c != NULL; c = c->next) { bool is_src = false; bool is_feat = false; e = name_equals(&c->name, "src", &is_src); if (e != NULL) { goto done; } e = name_equals(&c->name, "feature", &is_feat); if (e != NULL) { goto done; } if (is_src || is_feat) { e = validate_target_child(c, ctx, is_src); if (e != NULL) { goto done; } continue; } { char cname[65]; name_into(&c->name, cname, sizeof cname); snprintf(msg, sizeof msg, "%s has unknown child node '%s' (expected 'src' or " "'feature')", ctx, cname); e = err_at_owned(c->name.span, msg); goto done; } } e = NULL; done: free(name_str); 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. 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 name[65]; struct st_error *e; char *name_str = NULL; struct st_kdl_node *c; e = one_name_arg(n, "feature", &name_str); if (e != NULL) { return e; } name_into(&n->args->value, name, sizeof name); 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 " "'%s'", name, key); e = err_at_owned(n->props->key.span, msg); goto done; } for (c = n->children; c != NULL; c = c->next) { /* * 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. */ enum st_check_kind kind; struct st_error *ke = NULL; kind = st_check_kind_from_node(c, &ke); if (ke != NULL) { e = ke; goto done; } e = validate_check(c, kind, name); if (e != NULL) { goto done; } } e = NULL; done: free(name_str); return e; } /* option "name" (default=)?: the name shape, no children, and at * most one `default` property whose value must be an unannotated boolean * (#true/#false) once interpreted through the value model. */ static struct st_error * validate_option(const struct st_kdl_node *n) { char msg[256]; char name[65]; struct st_error *e; char *name_str = NULL; struct st_kdl_prop *p; bool seen_default = false; e = one_name_arg(n, "option", &name_str); if (e != NULL) { return e; } name_into(&n->args->value, name, sizeof name); if (n->children != NULL) { snprintf(msg, sizeof msg, "option '%s' takes no children", name); e = err_at_owned(n->children->name.span, msg); goto done; } for (p = n->props; p != NULL; p = p->next) { bool is_def = false; e = name_equals(&p->key, "default", &is_def); if (e != NULL) { goto done; } if (!is_def) { char key[65]; name_into(&p->key, key, sizeof key); snprintf(msg, sizeof msg, "option '%s' has unexpected property '%s' (only 'default' " "is allowed)", name, key); e = err_at_owned(p->key.span, msg); goto done; } if (seen_default) { snprintf(msg, sizeof msg, "option '%s' has a duplicate 'default' property", name); e = err_at_owned(p->key.span, msg); goto done; } seen_default = true; { struct st_kdl_value v; e = value_interpret(&p->value, p->annotation, &v); if (e != NULL) { goto done; } if (v.annotation != NULL) { snprintf(msg, sizeof msg, "option '%s': 'default' must not carry a type " "annotation", name); e = err_at_owned(p->value.span, msg); st_kdl_value_free(&v); goto done; } if (v.kind != ST_KDL_VAL_BOOL) { snprintf(msg, sizeof msg, "option '%s': 'default' must be a boolean (got %s)", name, st_kdl_value_kind_name(v.kind)); e = err_at_owned(p->value.span, msg); st_kdl_value_free(&v); goto done; } st_kdl_value_free(&v); } } e = NULL; done: free(name_str); return e; } /* ---- top level -------------------------------------------------------- */ /* Classify a top-level node name. */ enum top_kind { TOP_PROJECT = 0, TOP_TARGET, TOP_FEATURE, TOP_OPTION, TOP_UNKNOWN, }; static struct st_error * classify_top(const struct st_kdl_node *n, enum top_kind *out) { static const char *const names[] = { "project", "target", "feature", "option" }; static const enum top_kind kinds[] = { TOP_PROJECT, TOP_TARGET, TOP_FEATURE, TOP_OPTION }; size_t i; for (i = 0; i < sizeof(names) / sizeof(names[0]); i++) { bool eq = false; struct st_error *e = name_equals(&n->name, names[i], &eq); if (e != NULL) { return e; } if (eq) { *out = kinds[i]; return NULL; } } *out = TOP_UNKNOWN; return NULL; } /* ---- public API ------------------------------------------------------- */ struct st_error * st_kdl_validate(const struct st_kdl_document *doc) { struct st_kdl_node *n; bool saw_goal = false; /* saw at least one target or feature */ struct st_error *e; if (doc == NULL || doc->nodes == NULL) { /* No source position (or file name) exists to point at. */ return st_error_kdl_schema("missing required 'project' node"); } n = doc->nodes; { enum top_kind kind; e = classify_top(n, &kind); if (e != NULL) { return e; } if (kind != TOP_PROJECT) { char nm[65]; char msg[192]; name_into(&n->name, nm, sizeof nm); snprintf(msg, sizeof msg, "expected 'project' as the first top-level node, got '%s'", nm); return err_at_owned(n->name.span, msg); } e = validate_project(n); if (e != NULL) { return e; } } for (n = n->next; n != NULL; n = n->next) { enum top_kind kind; e = classify_top(n, &kind); if (e != NULL) { return e; } switch (kind) { case TOP_PROJECT: return err_at_owned(n->name.span, "duplicate 'project' node"); case TOP_TARGET: e = validate_target(n); if (e != NULL) { return e; } saw_goal = true; break; case TOP_FEATURE: e = validate_feature(n); if (e != NULL) { return e; } saw_goal = true; break; case TOP_OPTION: e = validate_option(n); if (e != NULL) { return e; } break; case TOP_UNKNOWN: { char nm[65]; char msg[192]; name_into(&n->name, nm, sizeof nm); snprintf(msg, sizeof msg, "unknown top-level node '%s' (expected 'target', " "'feature', or 'option')", nm); return err_at_owned(n->name.span, msg); } } } if (!saw_goal) { return err_at_owned(doc->nodes->name.span, "missing 'target' or 'feature' node after 'project'"); } return NULL; }