feat(ext): add extension discovery and loading

Extension discovery (plan todo 20): scans --ext-dir dirs,
STUPIDTOOLS_EXT, the XDG user dir and a builtin-ext dir for *.lua
modules, runs each in the sandboxed Lua runtime, and enumerates its
registrations; the builtin C/C++ modules load first via
st_ext_init_builtins. Fail-fast with an error naming file and line on
any malformed/sandbox-violating module.

The CLI gains a repeatable --ext-dir flag; with flag or env present
main.c runs discovery and prints 'loaded extension: ...' lines from
actual registrations (interim wiring until todo 23).

Note: src/Makefile.am gained the ext + vendored Lua sources because
the binary otherwise cannot link discovery -- required for the
root-verifiable CLI acceptance.
This commit is contained in:
2026-08-28 21:17:05 -04:00
parent 88d78990d5
commit 783019dff4
8 changed files with 1286 additions and 2 deletions
+45
View File
@@ -0,0 +1,45 @@
# builtin-ext/ — builtin extension directory
The directory of extension modules that ship with stupidtools (plan
todo 20). It is currently empty on purpose; it exists to lock the
convention.
## What goes here
Lua extension modules (`*.lua`) that load by default on every run. A
module registers checks and languages through the sandboxed runtime's
`st` API:
```lua
st.register_check("magic")
st.register_language("fortran")
```
Only regular files with a `.lua` suffix are loaded (dotfiles are
skipped); anything else here — this README included — is ignored by
discovery. Files load in bytewise-lexicographic order.
## How discovery finds this directory
`st_ext_discover()` (src/ext/discovery.c) scans this directory LAST in
the search order:
1. each `--ext-dir` directory (repeatable flag),
2. each `STUPIDTOOLS_EXT` entry (colon-separated environment list),
3. the user directory: `$XDG_DATA_HOME/stupidtools/ext`, falling back
to `$HOME/.local/share/stupidtools/ext`,
4. this directory.
The location is the `STUPIDTOOLS_BUILTIN_EXT_DIR` macro — currently the
literal `builtin-ext`, resolved relative to the working directory of
the running binary (fine for the in-repo layout). When an installed
layout arrives, the build will override it at compile time, e.g.
`-DSTUPIDTOOLS_BUILTIN_EXT_DIR='"<prefix>/share/stupidtools/ext"'`, and
ship modules here.
## Why it is empty today
The builtin C and C++ language modules are compiled C code
(`src/ext/lang_c.c`, `src/ext/lang_cpp.c`), not Lua, so nothing needs
to live here yet. The first shipped Lua module arrives with the example
extension todo and could serve as a fixture for this location.
+25 -2
View File
@@ -3,5 +3,28 @@
AM_CFLAGS = -std=c23 -Wall -Wextra -Wpedantic AM_CFLAGS = -std=c23 -Wall -Wextra -Wpedantic
bin_PROGRAMS = stupidtools bin_PROGRAMS = stupidtools
stupidtools_SOURCES = main.c cli.c stupidtools_SOURCES = \
noinst_HEADERS = cli.h main.c cli.c \
error.c span.c \
ext/discovery.c ext/lua.c ext/abi.c ext/lang_c.c ext/lang_cpp.c \
../thirdparty/lua/lapi.c ../thirdparty/lua/lauxlib.c \
../thirdparty/lua/lbaselib.c ../thirdparty/lua/lcode.c \
../thirdparty/lua/lctype.c ../thirdparty/lua/ldebug.c \
../thirdparty/lua/ldo.c ../thirdparty/lua/ldump.c \
../thirdparty/lua/lfunc.c ../thirdparty/lua/lgc.c \
../thirdparty/lua/llex.c ../thirdparty/lua/lmem.c \
../thirdparty/lua/lobject.c ../thirdparty/lua/lopcodes.c \
../thirdparty/lua/lparser.c ../thirdparty/lua/lstate.c \
../thirdparty/lua/lstring.c ../thirdparty/lua/ltable.c \
../thirdparty/lua/ltm.c ../thirdparty/lua/lundump.c \
../thirdparty/lua/lvm.c ../thirdparty/lua/lzio.c \
../thirdparty/lua/lstrlib.c ../thirdparty/lua/ltablib.c
# The vendored Lua subset above deliberately EXCLUDES linit.c,
# lmathlib.c, loadlib.c, liolib.c, loslib.c, ldblib.c, lcorolib.c,
# lutf8lib.c and the lua.c/luac.c mains -- see src/ext/lua.c's -lm note
# (the runtime's curated math/os tables replace lmathlib/loslib, and
# the six libm shims in lua.c keep the link free of -lm).
noinst_HEADERS = cli.h error.h span.h \
ext/discovery.h ext/lua.h ext/abi.h ext/lang_c.h ext/lang_cpp.h \
ext/lang_common.h
+35
View File
@@ -42,6 +42,9 @@ cli_print_usage(FILE *stream, const char *program)
"Options:\n" "Options:\n"
" -h, --help show this help and exit\n" " -h, --help show this help and exit\n"
" -V, --version print version and exit\n" " -V, --version print version and exit\n"
" --ext-dir DIR load extensions from DIR (repeatable; the\n"
" STUPIDTOOLS_EXT environment variable, a\n"
" colon-separated directory list, is honored too)\n"
"\n" "\n"
"Note: buildfile processing is not yet implemented.\n", "Note: buildfile processing is not yet implemented.\n",
program); program);
@@ -70,6 +73,7 @@ cli_parse(int argc, char **argv, struct cli_opts *opts)
opts->program = program_name(argc > 0 ? argv[0] : NULL); opts->program = program_name(argc > 0 ? argv[0] : NULL);
opts->buildfile = NULL; opts->buildfile = NULL;
opts->ext_dir_count = 0;
for (i = 1; i < argc; i++) { for (i = 1; i < argc; i++) {
const char *arg = argv[i]; const char *arg = argv[i];
@@ -101,6 +105,37 @@ cli_parse(int argc, char **argv, struct cli_opts *opts)
return CLI_ACTION_VERSION; return CLI_ACTION_VERSION;
} }
/* --ext-dir consumes the NEXT argument unconditionally
* (getopt-style; even if it starts with '-'), is repeatable,
* and rejects a missing/empty value and more than
* CLI_MAX_EXT_DIRS repetitions as usage errors. The value is
* borrowed from argv; a dir that does not exist is not an
* error -- discovery skips missing dirs silently. */
if (strcmp(arg, "--ext-dir") == 0) {
if (i + 1 >= argc) {
fprintf(stderr, "%s: option '--ext-dir' "
"requires an argument\n", opts->program);
cli_print_usage(stderr, opts->program);
return CLI_ACTION_ERROR;
}
i++;
if (argv[i][0] == '\0') {
fprintf(stderr, "%s: option '--ext-dir' "
"requires a non-empty directory\n",
opts->program);
cli_print_usage(stderr, opts->program);
return CLI_ACTION_ERROR;
}
if (opts->ext_dir_count >= CLI_MAX_EXT_DIRS) {
fprintf(stderr, "%s: too many --ext-dir options "
"(max %d)\n", opts->program, CLI_MAX_EXT_DIRS);
cli_print_usage(stderr, opts->program);
return CLI_ACTION_ERROR;
}
opts->ext_dirs[opts->ext_dir_count++] = argv[i];
continue;
}
fprintf(stderr, "%s: unrecognized option '%s'\n", fprintf(stderr, "%s: unrecognized option '%s'\n",
opts->program, arg); opts->program, arg);
cli_print_usage(stderr, opts->program); cli_print_usage(stderr, opts->program);
+8
View File
@@ -17,6 +17,9 @@
#define CLI_EXIT_RUNTIME 1 #define CLI_EXIT_RUNTIME 1
#define CLI_EXIT_USAGE 2 #define CLI_EXIT_USAGE 2
/* Sane cap on repeated --ext-dir flags (todo 20). */
#define CLI_MAX_EXT_DIRS 32
/* What the caller should do after parsing argv. */ /* What the caller should do after parsing argv. */
enum cli_action { enum cli_action {
CLI_ACTION_RUN, /* proceed with the parsed buildfile (todo 16+) */ CLI_ACTION_RUN, /* proceed with the parsed buildfile (todo 16+) */
@@ -28,6 +31,11 @@ enum cli_action {
struct cli_opts { struct cli_opts {
const char *program; /* argv[0] as given (or a default) */ const char *program; /* argv[0] as given (or a default) */
const char *buildfile; /* single positional buildfile, NULL if none */ const char *buildfile; /* single positional buildfile, NULL if none */
/* --ext-dir values, in the order given; the pointers are borrowed
* from argv. STUPIDTOOLS_EXT is NOT merged here -- discovery reads
* the environment itself and appends those dirs after these. */
const char *ext_dirs[CLI_MAX_EXT_DIRS];
size_t ext_dir_count;
}; };
enum cli_action cli_parse(int argc, char **argv, struct cli_opts *opts); enum cli_action cli_parse(int argc, char **argv, struct cli_opts *opts);
+458
View File
@@ -0,0 +1,458 @@
/*
* src/ext/discovery.c - extension discovery and loading (plan todo 20).
*
* See src/ext/discovery.h for the full contract: the discovery order
* (builtin C modules -> --ext-dir -> STUPIDTOOLS_EXT -> user dir ->
* builtin dir), the per-directory scan rules (regular *.lua files,
* bytewise order, missing dirs are no-ops), and the FAIL-FAST error
* contract ("extension load failed: <path>: ...").
*
* Everything loads through the sandboxed Lua runtime (st_lua_run); the
* builtin C/C++ language modules go through st_ext_init_builtins() into
* the ABI ctx. No language is hardcoded here.
*
* Copyright (c) 2026 huntedbytheirs
* SPDX-License-Identifier: BSD-3-Clause
*/
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L /* strdup, strtok_r, stat, readdir */
#endif
#include <dirent.h>
#include <errno.h>
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include "abi.h"
#include "discovery.h"
#include "lua.h"
/* Compile-time override for the shipped builtin extension directory;
* the default is resolved relative to the current working directory
* (repo-layout friendly -- see builtin-ext/README.md). An installed
* layout overrides it, e.g.
* -DSTUPIDTOOLS_BUILTIN_EXT_DIR='"<prefix>/share/stupidtools/ext"'
*/
#ifndef STUPIDTOOLS_BUILTIN_EXT_DIR
#define STUPIDTOOLS_BUILTIN_EXT_DIR "builtin-ext"
#endif
/* ---------------- logging ---------------- */
/* NULL -> default stderr logger. The one piece of module state; the
* task explicitly asks for a minimal hook, and threading a logger
* parameter through the whole chain would churn the public signature. */
static st_ext_log_fn g_log_fn;
void
st_ext_discover_set_log(st_ext_log_fn fn)
{
g_log_fn = fn;
}
void
st_ext_discover_log(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
if (g_log_fn != NULL) {
g_log_fn(fmt, ap);
} else {
vfprintf(stderr, fmt, ap);
}
va_end(ap);
}
/* ---------------- small helpers ---------------- */
/* Comma-joined names of registrations [from, count) via the accessor;
* malloc'd, NULL when the range is empty or on allocation failure. */
static char *
names_join(const struct st_lua_rt *rt, size_t from, size_t count,
const char *(*name_at)(const struct st_lua_rt *, size_t))
{
size_t i, total = 0;
char *buf, *p;
if (count <= from) return NULL;
for (i = from; i < count; i++) {
const char *n = name_at(rt, i);
if (n != NULL) total += strlen(n) + 2; /* ", " separator */
}
if (total == 0) return NULL;
buf = malloc(total + 1);
if (buf == NULL) return NULL;
p = buf;
for (i = from; i < count; i++) {
const char *n = name_at(rt, i);
size_t len;
if (n == NULL) continue;
len = strlen(n);
if (p != buf) {
*p++ = ',';
*p++ = ' ';
}
memcpy(p, n, len);
p += len;
}
*p = '\0';
return buf;
}
/* "c, cxx" style list of the ABI ctx's language names (malloc'd). */
static char *
abi_language_names(const struct st_ext_ctx *ctx)
{
size_t i, n = st_ext_language_count(ctx), total = 0;
char *buf, *p;
for (i = 0; i < n; i++) {
const char *nm = st_ext_language_name(ctx, i);
if (nm != NULL) total += strlen(nm) + 2;
}
if (total == 0) return NULL;
buf = malloc(total + 1);
if (buf == NULL) return NULL;
p = buf;
for (i = 0; i < n; i++) {
const char *nm = st_ext_language_name(ctx, i);
size_t len;
if (nm == NULL) continue;
len = strlen(nm);
if (p != buf) {
*p++ = ',';
*p++ = ' ';
}
memcpy(p, nm, len);
p += len;
}
*p = '\0';
return buf;
}
/* Reads a whole file into a freshly malloc'd NUL-terminated buffer.
* NULL on success (buffer in *out, length in *out_len); an st_error on
* failure (I/O -> ST_ERR_IO naming the file, OOM -> ST_ERR_INTERNAL). */
static struct st_error *
read_module_source(const char *path, char **out, size_t *out_len)
{
FILE *f;
long size;
char *buf;
size_t got;
size_t need;
struct st_error *err;
f = fopen(path, "rb");
if (f == NULL) goto io_fail;
if (fseek(f, 0, SEEK_END) != 0) {
(void)fclose(f);
goto io_fail;
}
size = ftell(f);
if (size < 0 || fseek(f, 0, SEEK_SET) != 0) {
(void)fclose(f);
goto io_fail;
}
buf = malloc((size_t)size + 1);
if (buf == NULL) {
(void)fclose(f);
return st_error_internal("extension load failed: out of memory");
}
got = fread(buf, 1, (size_t)size, f);
if (got != (size_t)size || fclose(f) != 0) {
free(buf);
goto io_fail;
}
buf[size] = '\0';
*out = buf;
*out_len = (size_t)size;
return NULL;
io_fail:
need = strlen(path) + 64;
buf = malloc(need);
if (buf == NULL)
return st_error_internal("extension load failed: out of memory");
(void)snprintf(buf, need, "extension load failed: %s: %s", path,
strerror(errno));
err = st_error_io(buf);
free(buf);
return err;
}
/* Wraps the runtime's error so the message names the module file. The
* category is preserved (sandbox violations stay ST_ERR_KDL_SCHEMA so
* st_error_print keeps its [kdl-schema] marker); the runtime's own
* message already carries "<file>:<line>: " via chunkname propagation. */
static struct st_error *
module_load_error(struct st_error *inner, const char *path)
{
enum st_error_category cat = st_error_category_of(inner);
const char *msg = st_error_message(inner);
size_t need = strlen(path) + (msg != NULL ? strlen(msg) : 12) + 32;
char *buf = malloc(need);
struct st_error *err;
if (buf == NULL) {
st_error_free(inner);
return st_error_internal("extension load failed: out of memory");
}
(void)snprintf(buf, need, "extension load failed: %s: %s", path,
msg != NULL ? msg : "(no message)");
err = st_error_new(cat, buf);
free(buf);
st_error_free(inner);
return err;
}
/* Logs one loaded module with the registrations it added, computed from
* the runtime's counts before/after the load (registrations only ever
* append, so the additions are exactly [before, after)). */
static void
log_module(struct st_lua_rt *rt, const char *path, size_t checks_before,
size_t langs_before)
{
size_t checks_after = st_lua_check_count(rt);
size_t langs_after = st_lua_language_count(rt);
char *ck = names_join(rt, checks_before, checks_after, st_lua_check_name);
char *lg = names_join(rt, langs_before, langs_after,
st_lua_language_name);
if (ck != NULL && lg != NULL) {
st_ext_discover_log("loaded extension: %s — checks: %zu (%s) "
"languages: %zu (%s)\n", path,
checks_after - checks_before, ck,
langs_after - langs_before, lg);
} else if (ck != NULL) {
st_ext_discover_log("loaded extension: %s — checks: %zu (%s) "
"languages: %zu\n", path, checks_after - checks_before, ck,
langs_after - langs_before);
} else if (lg != NULL) {
st_ext_discover_log("loaded extension: %s — checks: %zu "
"languages: %zu (%s)\n", path, checks_after - checks_before,
langs_after - langs_before, lg);
} else {
st_ext_discover_log("loaded extension: %s — checks: %zu "
"languages: %zu\n", path, checks_after - checks_before,
langs_after - langs_before);
}
free(ck);
free(lg);
}
/* Logs the builtin C modules from the ACTUAL ABI registrations. */
static void
log_builtins(const struct st_ext_ctx *ctx)
{
char *names = abi_language_names(ctx);
if (names != NULL) {
st_ext_discover_log("loaded builtin modules: languages: %s\n",
names);
free(names);
} else {
st_ext_discover_log("loaded builtin modules\n");
}
}
/* Loads one module file into the sandbox. NULL on success; on failure a
* wrapped error naming the file (the fail-fast contract). */
static struct st_error *
load_module(struct st_lua_rt *rt, const char *path)
{
char *src = NULL;
size_t len = 0;
size_t c_before = st_lua_check_count(rt);
size_t l_before = st_lua_language_count(rt);
struct st_error *err = read_module_source(path, &src, &len);
if (err != NULL) return err;
err = st_lua_run(rt, src, path); /* chunkname = full path */
free(src);
if (err != NULL) return module_load_error(err, path);
log_module(rt, path, c_before, l_before);
return NULL;
}
static int
cmp_str(const void *a, const void *b)
{
const char *const *sa = a;
const char *const *sb = b;
return strcmp(*sa, *sb);
}
/* Scans one directory for *.lua modules and loads them in bytewise
* order. NULL on success; a missing directory is a documented no-op. */
static struct st_error *
discover_dir(struct st_lua_rt *rt, const char *dir)
{
DIR *d = opendir(dir);
struct dirent *e;
char **names = NULL;
size_t n = 0, cap = 0, i;
struct st_error *err = NULL;
if (d == NULL) return NULL; /* missing dir: silent no-op */
while ((e = readdir(d)) != NULL) {
size_t nlen;
if (e->d_name[0] == '.') continue; /* ., .. and dotfiles */
nlen = strlen(e->d_name);
if (nlen < 4 || strcmp(e->d_name + nlen - 4, ".lua") != 0)
continue;
if (n == cap) {
size_t ncap = cap == 0 ? 16 : cap * 2;
char **nn = realloc(names, ncap * sizeof *nn);
if (nn == NULL) {
err = st_error_internal("extension load failed: "
"out of memory");
break;
}
names = nn;
cap = ncap;
}
names[n] = strdup(e->d_name);
if (names[n] == NULL) {
err = st_error_internal("extension load failed: "
"out of memory");
break;
}
n++;
}
(void)closedir(d);
if (err != NULL) {
for (i = 0; i < n; i++) free(names[i]);
free(names);
return err;
}
qsort(names, n, sizeof *names, cmp_str);
for (i = 0; i < n && err == NULL; i++) {
char *path;
struct stat st;
size_t dlen = strlen(dir);
size_t nlen = strlen(names[i]);
while (dlen > 0 && dir[dlen - 1] == '/') dlen--;
path = malloc(dlen + 1 + nlen + 1);
if (path == NULL) {
err = st_error_internal("extension load failed: "
"out of memory");
break;
}
memcpy(path, dir, dlen);
path[dlen] = '/';
memcpy(path + dlen + 1, names[i], nlen + 1);
if (stat(path, &st) != 0 || !S_ISREG(st.st_mode)) {
/* vanished between readdir and stat, or non-regular
* (a directory named *.lua etc.): skip */
free(path);
continue;
}
err = load_module(rt, path);
free(path);
}
for (i = 0; i < n; i++) free(names[i]);
free(names);
return err;
}
/* STUPIDTOOLS_EXT: colon-separated directory list (no escaping); empty
* entries are skipped so a trailing colon is harmless. */
static struct st_error *
discover_env_dirs(struct st_lua_rt *rt)
{
const char *env = getenv("STUPIDTOOLS_EXT");
char *copy;
char *save = NULL;
char *tok;
struct st_error *err = NULL;
if (env == NULL || env[0] == '\0') return NULL;
copy = strdup(env);
if (copy == NULL)
return st_error_internal("extension load failed: out of memory");
for (tok = strtok_r(copy, ":", &save); tok != NULL && err == NULL;
tok = strtok_r(NULL, ":", &save)) {
if (tok[0] != '\0') err = discover_dir(rt, tok);
}
free(copy);
return err;
}
/* User extension dir: $XDG_DATA_HOME/stupidtools/ext (canonical), else
* $HOME/.local/share/stupidtools/ext; skipped when neither is usable. */
static struct st_error *
discover_user_dir(struct st_lua_rt *rt)
{
const char *xdg = getenv("XDG_DATA_HOME");
const char *home;
char *dir;
size_t need;
struct st_error *err;
if (xdg == NULL || xdg[0] == '\0') {
home = getenv("HOME");
if (home == NULL || home[0] == '\0') return NULL;
need = strlen(home) + sizeof "/.local/share/stupidtools/ext";
dir = malloc(need);
if (dir == NULL)
return st_error_internal("extension load failed: "
"out of memory");
(void)snprintf(dir, need, "%s/.local/share/stupidtools/ext",
home);
} else {
need = strlen(xdg) + sizeof "/stupidtools/ext";
dir = malloc(need);
if (dir == NULL)
return st_error_internal("extension load failed: "
"out of memory");
(void)snprintf(dir, need, "%s/stupidtools/ext", xdg);
}
err = discover_dir(rt, dir);
free(dir);
return err;
}
/* ---------------- public entry point ---------------- */
struct st_error *
st_ext_discover(struct st_ext_ctx *ctx, struct st_lua_rt *rt,
const char *const *extra_dirs, size_t n_dirs)
{
struct st_error *err;
size_t i;
if (rt == NULL) return st_error_internal("st_ext_discover: NULL runtime");
if (ctx == NULL) return st_error_usage("st_ext_discover: NULL extension context");
/* Builtin C/C++ language modules first -- the foundation; the
* builtin-ext DIRECTORY is scanned below with the other dirs. */
err = st_ext_init_builtins(ctx);
if (err != NULL) return err;
log_builtins(ctx);
for (i = 0; i < n_dirs && err == NULL; i++) {
if (extra_dirs[i] != NULL && extra_dirs[i][0] != '\0')
err = discover_dir(rt, extra_dirs[i]);
}
if (err != NULL) return err;
err = discover_env_dirs(rt);
if (err != NULL) return err;
err = discover_user_dir(rt);
if (err != NULL) return err;
return discover_dir(rt, STUPIDTOOLS_BUILTIN_EXT_DIR);
}
+95
View File
@@ -0,0 +1,95 @@
#ifndef ST_EXT_DISCOVERY_H
#define ST_EXT_DISCOVERY_H
/*
* Extension discovery and loading (plan todo 20).
*
* st_ext_discover() loads every extension known to a stupidtools run
* into a freshly created ABI context + sandboxed Lua runtime. Search
* order:
*
* 1. the BUILTIN C language modules -- st_ext_init_builtins() puts
* the "c"/"cxx" languages (and their checks) into the ABI ctx;
* 2. each --ext-dir directory (extra_dirs, in the given order);
* 3. each STUPIDTOOLS_EXT directory (colon-separated, empty entries
* skipped);
* 4. the USER directory: $XDG_DATA_HOME/stupidtools/ext, falling
* back to $HOME/.local/share/stupidtools/ext when XDG_DATA_HOME is
* unset or empty; skipped entirely when neither variable yields a
* path. XDG_DATA_HOME is the canonical convention.
* 5. the BUILTIN directory: the STUPIDTOOLS_BUILTIN_EXT_DIR macro
* (default "builtin-ext", resolved relative to the current working
* directory of the running binary; an installed layout overrides
* it at compile time, e.g.
* -DSTUPIDTOOLS_BUILTIN_EXT_DIR='"<prefix>/share/stupidtools/ext"').
*
* Directory scanning contract:
* - only REGULAR files whose name ends in ".lua" are loaded (dotfiles
* excluded). Entries arrive from readdir() as bare basenames -- no
* directory entry can contain a path separator, so a malicious
* name like "../evil.lua" cannot occur, let alone escape the dir.
* Symlinks are followed (stat); pointing one at a file outside the
* directory is the caller's own responsibility in an explicitly
* configured dir.
* - within a directory, modules load in bytewise-lexicographic order
* (deterministic, independent of readdir order);
* - a directory that does not exist is silently skipped (a no-op --
* env lists routinely contain stale paths); an empty directory is
* likewise a no-op; an EMPTY .lua file loads as an empty chunk and
* is a clean no-op;
* - each module is read into memory and run via st_lua_run() with the
* full path as chunkname, so Lua diagnostics name file AND line.
* Note: st_lua_run() compiles strlen(chunk) bytes, so a file with
* embedded NULs is truncated at the first NUL before compilation;
* binary garbage then fails with a syntax error naming the file.
*
* Failure contract: FAIL-FAST. The first module that fails to load
* (syntax error, runtime error, sandbox violation, unreadable file)
* aborts discovery; the returned st_error's message starts with
* "extension load failed: <path>: ..." and preserves the underlying
* category (sandbox violations stay ST_ERR_KDL_SCHEMA, other Lua
* failures ST_ERR_INTERNAL, read failures ST_ERR_IO). Later modules
* are NOT attempted. Deliberate choice: a broken extension should be
* loud, and a half-loaded extension set is a confusing state.
*
* Lifecycle notes:
* - st_ext_discover() calls st_ext_init_builtins() itself; call it
* ONCE per ctx (a second call re-registers the builtins and fails
* on duplicate registrations);
* - the ABI ctx receives the builtin modules only; Lua registrations
* stay in the runtime's own registries (st_lua_check_* /
* st_lua_language_*) -- todo 21 bridges them into the ctx.
*
* Logging: st_ext_discover_log() prints to stderr by default;
* st_ext_discover_set_log() installs a vfprintf-style callback (NULL
* restores the default). Discovery logs one line per loaded module:
*
* loaded extension: <path> — checks: <n> (<names>) languages: <m> (<names>)
*
* where the counts and names come from the ACTUAL registrations the
* module performed (enumerated from the runtime), never from the file
* name alone. The builtin modules log one line of the same shape.
*/
#include <stdarg.h>
#include <stddef.h>
#include "error.h"
struct st_ext_ctx;
struct st_lua_rt;
/* vfprintf-style log sink (NULL restores the default stderr logger). */
typedef void (*st_ext_log_fn)(const char *fmt, va_list ap);
void st_ext_discover_set_log(st_ext_log_fn fn);
void st_ext_discover_log(const char *fmt, ...);
/* Returns NULL on success. `extra_dirs` are the --ext-dir values (may
* be NULL/0); see the header contract for the full discovery order and
* the fail-fast failure semantics. */
struct st_error *st_ext_discover(struct st_ext_ctx *ctx, struct st_lua_rt *rt,
const char *const *extra_dirs,
size_t n_dirs);
#endif /* ST_EXT_DISCOVERY_H */
+59
View File
@@ -7,17 +7,68 @@
* Entrypoint: parse argv via cli.c, dispatch --help/--version/usage * Entrypoint: parse argv via cli.c, dispatch --help/--version/usage
* errors, and reserve the `stupidtools <buildfile>` shape. Buildfile * errors, and reserve the `stupidtools <buildfile>` shape. Buildfile
* processing arrives in later todos (16+). * processing arrives in later todos (16+).
*
* Interim extension pipeline (todo 20): with --ext-dir flags or a
* STUPIDTOOLS_EXT environment, run extension discovery, print what
* loaded, and exit 0 -- until todo 23 folds discovery into the full
* buildfile pipeline.
*/ */
#include "cli.h" #include "cli.h"
#include "ext/abi.h"
#include "ext/discovery.h"
#include "ext/lua.h"
#include <stdarg.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
/* Discovery's log sink: route the "loaded extension: ..." lines to
* stdout so scripts can grep them cleanly. */
static void
stdout_log(const char *fmt, va_list ap)
{
vfprintf(stdout, fmt, ap);
}
/* Creates a fresh ctx + runtime, discovers everything (builtin modules
* first, then the --ext-dir dirs, STUPIDTOOLS_EXT, the user dir and the
* builtin dir -- see ext/discovery.h), and prints each loaded module.
* Returns CLI_EXIT_OK on success; a load error prints its diagnostic to
* stderr and returns CLI_EXIT_RUNTIME (fail-fast contract). */
static int
run_extension_discovery(const struct cli_opts *opts)
{
struct st_ext_ctx *ctx = st_ext_ctx_new();
struct st_lua_rt *rt = st_lua_rt_new();
struct st_error *err = NULL;
int rc = CLI_EXIT_RUNTIME;
if (ctx == NULL || rt == NULL) {
fprintf(stderr, "%s: error: out of memory\n", opts->program);
} else {
st_ext_discover_set_log(stdout_log);
err = st_ext_discover(ctx, rt, opts->ext_dirs,
opts->ext_dir_count);
if (err != NULL) {
st_error_print(stderr, err);
st_error_free(err);
} else {
rc = CLI_EXIT_OK;
}
}
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
return rc;
}
int int
main(int argc, char **argv) main(int argc, char **argv)
{ {
struct cli_opts opts; struct cli_opts opts;
enum cli_action action; enum cli_action action;
const char *env;
action = cli_parse(argc, argv, &opts); action = cli_parse(argc, argv, &opts);
@@ -33,6 +84,14 @@ main(int argc, char **argv)
break; break;
} }
if (opts.ext_dir_count > 0) {
return run_extension_discovery(&opts);
}
env = getenv("STUPIDTOOLS_EXT");
if (env != NULL && env[0] != '\0') {
return run_extension_discovery(&opts);
}
if (opts.buildfile == NULL) { if (opts.buildfile == NULL) {
fprintf(stderr, "%s: error: no build file given\n", fprintf(stderr, "%s: error: no build file given\n",
opts.program); opts.program);
+561
View File
@@ -0,0 +1,561 @@
/* LINK: ../../src/ext/discovery.c ../../src/ext/lua.c ../../src/ext/abi.c ../../src/ext/lang_c.c ../../src/ext/lang_cpp.c ../../src/error.c ../../src/span.c ../../thirdparty/lua/lapi.c ../../thirdparty/lua/lauxlib.c ../../thirdparty/lua/lbaselib.c ../../thirdparty/lua/lcode.c ../../thirdparty/lua/lctype.c ../../thirdparty/lua/ldebug.c ../../thirdparty/lua/ldo.c ../../thirdparty/lua/ldump.c ../../thirdparty/lua/lfunc.c ../../thirdparty/lua/lgc.c ../../thirdparty/lua/llex.c ../../thirdparty/lua/lmem.c ../../thirdparty/lua/lobject.c ../../thirdparty/lua/lopcodes.c ../../thirdparty/lua/lparser.c ../../thirdparty/lua/lstate.c ../../thirdparty/lua/lstring.c ../../thirdparty/lua/ltable.c ../../thirdparty/lua/ltm.c ../../thirdparty/lua/lundump.c ../../thirdparty/lua/lvm.c ../../thirdparty/lua/lzio.c ../../thirdparty/lua/lstrlib.c ../../thirdparty/lua/ltablib.c */
#ifndef _POSIX_C_SOURCE
#define _POSIX_C_SOURCE 200809L /* mkdtemp, strdup */
#endif
/*
* tests/unit/test_discovery.c
*
* Unit tests for extension discovery and loading (src/ext/discovery.h,
* plan todo 20).
*
* The magic LINK comment on line 1 is REQUIRED by tests/run.sh: it
* lists the extra .c sources compiled into this test binary. It links
* discovery.c plus everything it needs -- the sandboxed Lua runtime
* (lua.c + the same 27 vendored Lua sources as test_lua.c, minus
* linit/lmathlib/loadlib/liolib/loslib/lua.c/luac.c, see the -lm note
* there), the extension ABI + builtin C/C++ modules, and error/span.
*
* Environment isolation: every test points XDG_DATA_HOME at an empty
* temp dir and unsets STUPIDTOOLS_EXT first, so host state can never
* leak into discovery. The builtin directory (builtin-ext relative to
* the cwd) ships no .lua modules in this todo, so it is a no-op as
* well; note that exact runtime-count assertions below assume that.
*/
#include "munit.h"
#include "ext/abi.h"
#include "ext/discovery.h"
#include "ext/lua.h"
#include <dirent.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
/* ---------- filesystem helpers ---------- */
static char *
make_temp_dir(void)
{
char *d = strdup("/tmp/st_discovery_XXXXXX");
if (d == NULL || mkdtemp(d) == NULL) {
free(d);
return NULL;
}
return d;
}
static int
write_file(const char *dir, const char *name, const char *content)
{
char path[1024];
FILE *f;
size_t len = strlen(content);
if (snprintf(path, sizeof path, "%s/%s", dir, name) >=
(int)sizeof path) {
return -1;
}
f = fopen(path, "wb");
if (f == NULL) return -1;
if (fwrite(content, 1, len, f) != len) {
(void)fclose(f);
return -1;
}
return fclose(f) == 0 ? 0 : -1;
}
static int
write_bytes(const char *dir, const char *name, const unsigned char *bytes,
size_t len)
{
char path[1024];
FILE *f;
if (snprintf(path, sizeof path, "%s/%s", dir, name) >=
(int)sizeof path) {
return -1;
}
f = fopen(path, "wb");
if (f == NULL) return -1;
if (fwrite(bytes, 1, len, f) != len) {
(void)fclose(f);
return -1;
}
return fclose(f) == 0 ? 0 : -1;
}
/* Recursive delete (plain POSIX: unlink files, rmdir dirs). */
static void
rm_rf(const char *path)
{
struct stat st;
DIR *d;
struct dirent *e;
if (lstat(path, &st) != 0) return;
if (!S_ISDIR(st.st_mode)) {
(void)unlink(path);
return;
}
d = opendir(path);
if (d == NULL) return;
while ((e = readdir(d)) != NULL) {
char sub[1024];
if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0)
continue;
if (snprintf(sub, sizeof sub, "%s/%s", path, e->d_name) >=
(int)sizeof sub)
continue;
rm_rf(sub);
}
(void)closedir(d);
(void)rmdir(path);
}
/* Fresh ctx + rt pair for one discovery run (discovery is one-shot per
* ctx: it re-runs the builtin init, so a second call would fail on
* duplicate registrations). */
static int
fresh_pair(struct st_ext_ctx **ctx, struct st_lua_rt **rt)
{
*ctx = st_ext_ctx_new();
*rt = st_lua_rt_new();
return *ctx != NULL && *rt != NULL ? 0 : -1;
}
/* ---------- (a) a valid module registers into the runtime ---------- */
static MunitResult
test_valid_module(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *ext = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
munit_assert_int(write_file(ext, "hello.lua",
"st.register_check(\"magic\")\n"
"st.register_language(\"fortran\")\n"), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_null(err);
/* the module's registrations are enumerable from the runtime */
munit_assert_size(st_lua_check_count(rt), ==, 1);
munit_assert_string_equal(st_lua_check_name(rt, 0), "magic");
munit_assert_size(st_lua_language_count(rt), ==, 1);
munit_assert_string_equal(st_lua_language_name(rt, 0), "fortran");
/* the builtin C/C++ modules landed in the ABI ctx */
munit_assert_size(st_ext_language_count(ctx), ==, 2);
munit_assert_string_equal(st_ext_language_name(ctx, 0), "c");
munit_assert_string_equal(st_ext_language_name(ctx, 1), "cxx");
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext);
rm_rf(xdg);
free(ext);
free(xdg);
return MUNIT_OK;
}
/* ---------- (b) malformed module: clear error naming the file, fail-fast ---------- */
static MunitResult
test_malformed_fail_fast(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *ext = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *msg;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
munit_assert_int(write_file(ext, "bad.lua", "local x =\n"), ==, 0);
munit_assert_int(write_file(ext, "good.lua",
"st.register_check(\"never\")\n"), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_not_null(err);
munit_assert_int(st_error_category_of(err), ==, ST_ERR_INTERNAL);
msg = st_error_message(err);
munit_assert_not_null(msg);
munit_assert_not_null(strstr(msg, "extension load failed"));
munit_assert_not_null(strstr(msg, "bad.lua"));
munit_assert_not_null(strstr(msg, ":2:")); /* file AND line named */
/* fail-fast: good.lua (which sorts after bad.lua) never ran */
munit_assert_size(st_lua_check_count(rt), ==, 0);
st_error_free(err);
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext);
rm_rf(xdg);
free(ext);
free(xdg);
return MUNIT_OK;
}
/* ---------- (c) a sandbox violation yields the sandbox error ---------- */
static MunitResult
test_sandbox_violation(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *ext = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *msg;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
munit_assert_int(write_file(ext, "evil.lua", "os.execute(\"id\")\n"),
==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_not_null(err);
munit_assert_int(st_error_category_of(err), ==, ST_ERR_KDL_SCHEMA);
msg = st_error_message(err);
munit_assert_not_null(msg);
munit_assert_not_null(strstr(msg, "sandbox blocked"));
munit_assert_not_null(strstr(msg, "evil.lua"));
st_error_free(err);
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext);
rm_rf(xdg);
free(ext);
free(xdg);
return MUNIT_OK;
}
/* ---------- (d) empty dir / missing dir / NULL dirs: clean no-op ---------- */
static MunitResult
test_empty_and_missing_dirs(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *empty = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *dirs[2];
(void)params;
(void)data;
xdg = make_temp_dir();
empty = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(empty);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
/* a nonexistent dir and an empty dir are both silent no-ops */
dirs[0] = "/nonexistent/st_discovery_dir";
dirs[1] = empty;
err = st_ext_discover(ctx, rt, dirs, 2);
munit_assert_null(err);
munit_assert_size(st_lua_check_count(rt), ==, 0);
munit_assert_size(st_lua_language_count(rt), ==, 0);
munit_assert_size(st_ext_language_count(ctx), ==, 2); /* builtins only */
/* NULL/0 extra dirs are valid too (env + user + builtin only) */
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
err = st_ext_discover(ctx, rt, NULL, 0);
munit_assert_null(err);
munit_assert_size(st_lua_check_count(rt), ==, 0);
/* discovery is one-shot per ctx: the builtin init re-runs and
* duplicate registrations fail (documented contract) */
err = st_ext_discover(ctx, rt, NULL, 0);
munit_assert_not_null(err);
st_error_free(err);
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(empty);
rm_rf(xdg);
free(empty);
free(xdg);
return MUNIT_OK;
}
/* ---------- (e) a binary file named *.lua yields a load error ---------- */
static MunitResult
test_binary_file(const MunitParameter params[], void *data)
{
static const unsigned char elf_head[] = { 0x7f, 'E', 'L', 'F', 0x02,
0x01, 0x01 };
char *xdg = NULL;
char *ext = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *msg;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
munit_assert_int(write_bytes(ext, "bin.lua", elf_head,
sizeof elf_head), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_not_null(err);
msg = st_error_message(err);
munit_assert_not_null(msg);
munit_assert_not_null(strstr(msg, "extension load failed"));
munit_assert_not_null(strstr(msg, "bin.lua"));
st_error_free(err);
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext);
rm_rf(xdg);
free(ext);
free(xdg);
return MUNIT_OK;
}
/* ---------- (f) non-.lua entries (and a DIR named *.lua) are ignored ---------- */
static MunitResult
test_ignores_non_lua(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *ext = NULL;
char sub[1024];
int sub_len;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
munit_assert_int(write_file(ext, "notes.txt",
"st.register_check(\"hidden\")\n"), ==, 0);
sub_len = snprintf(sub, sizeof sub, "%s/sub.lua", ext);
munit_assert_int(sub_len, >=, 0);
munit_assert_int(mkdir(sub, 0777), ==, 0);
munit_assert_int(write_file(ext, "sub.lua/inner.lua",
"st.register_check(\"also-hidden\")\n"), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_null(err);
munit_assert_size(st_lua_check_count(rt), ==, 0);
munit_assert_size(st_lua_language_count(rt), ==, 0);
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext);
rm_rf(xdg);
free(ext);
free(xdg);
return MUNIT_OK;
}
/* ---------- (g) modules load in bytewise-lexicographic order ---------- */
static MunitResult
test_deterministic_order(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *ext = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(unsetenv("STUPIDTOOLS_EXT"), ==, 0);
/* written in reverse order on purpose: discovery must sort */
munit_assert_int(write_file(ext, "b.lua",
"st.register_check(\"second\")\n"), ==, 0);
munit_assert_int(write_file(ext, "a.lua",
"st.register_check(\"first\")\n"), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_null(err);
munit_assert_size(st_lua_check_count(rt), ==, 2);
munit_assert_string_equal(st_lua_check_name(rt, 0), "first");
munit_assert_string_equal(st_lua_check_name(rt, 1), "second");
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext);
rm_rf(xdg);
free(ext);
free(xdg);
return MUNIT_OK;
}
/* ---------- (h) STUPIDTOOLS_EXT is honored, after the explicit dirs ---------- */
static MunitResult
test_env_dirs(const MunitParameter params[], void *data)
{
char *xdg = NULL;
char *ext1 = NULL;
char *ext2 = NULL;
struct st_ext_ctx *ctx = NULL;
struct st_lua_rt *rt = NULL;
struct st_error *err;
const char *dirs[1];
(void)params;
(void)data;
xdg = make_temp_dir();
ext1 = make_temp_dir();
ext2 = make_temp_dir();
munit_assert_not_null(xdg);
munit_assert_not_null(ext1);
munit_assert_not_null(ext2);
munit_assert_int(setenv("XDG_DATA_HOME", xdg, 1), ==, 0);
munit_assert_int(setenv("STUPIDTOOLS_EXT", ext2, 1), ==, 0);
munit_assert_int(write_file(ext1, "one.lua",
"st.register_check(\"one\")\n"), ==, 0);
munit_assert_int(write_file(ext2, "two.lua",
"st.register_check(\"two\")\n"), ==, 0);
munit_assert_int(fresh_pair(&ctx, &rt), ==, 0);
dirs[0] = ext1;
err = st_ext_discover(ctx, rt, dirs, 1);
munit_assert_null(err);
munit_assert_size(st_lua_check_count(rt), ==, 2);
munit_assert_string_equal(st_lua_check_name(rt, 0), "one");
munit_assert_string_equal(st_lua_check_name(rt, 1), "two");
st_lua_rt_free(rt);
st_ext_ctx_free(ctx);
rm_rf(ext1);
rm_rf(ext2);
rm_rf(xdg);
free(ext1);
free(ext2);
free(xdg);
return MUNIT_OK;
}
static MunitTest tests[] = {
{ "/discovery/valid-module", test_valid_module, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/malformed-fail-fast", test_malformed_fail_fast, NULL,
NULL, MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/sandbox-violation", test_sandbox_violation, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/empty-and-missing-dirs", test_empty_and_missing_dirs,
NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/binary-file", test_binary_file, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/ignores-non-lua", test_ignores_non_lua, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/deterministic-order", test_deterministic_order, NULL,
NULL, MUNIT_TEST_OPTION_NONE, NULL },
{ "/discovery/env-dirs", test_env_dirs, NULL, NULL,
MUNIT_TEST_OPTION_NONE, NULL },
{ NULL, NULL, NULL, NULL, MUNIT_TEST_OPTION_NONE, NULL },
};
static const MunitSuite suite = {
"/discovery", 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);
}