diff --git a/builtin-ext/README.md b/builtin-ext/README.md new file mode 100644 index 0000000..4c29c8e --- /dev/null +++ b/builtin-ext/README.md @@ -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='"/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. diff --git a/src/Makefile.am b/src/Makefile.am index 469638d..2715c8c 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -3,5 +3,28 @@ AM_CFLAGS = -std=c23 -Wall -Wextra -Wpedantic bin_PROGRAMS = stupidtools -stupidtools_SOURCES = main.c cli.c -noinst_HEADERS = cli.h +stupidtools_SOURCES = \ + 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 diff --git a/src/cli.c b/src/cli.c index a18c63e..a25e951 100644 --- a/src/cli.c +++ b/src/cli.c @@ -42,6 +42,9 @@ cli_print_usage(FILE *stream, const char *program) "Options:\n" " -h, --help show this help 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" "Note: buildfile processing is not yet implemented.\n", 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->buildfile = NULL; + opts->ext_dir_count = 0; for (i = 1; i < argc; i++) { const char *arg = argv[i]; @@ -101,6 +105,37 @@ cli_parse(int argc, char **argv, struct cli_opts *opts) 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", opts->program, arg); cli_print_usage(stderr, opts->program); diff --git a/src/cli.h b/src/cli.h index 42b86af..ce0239a 100644 --- a/src/cli.h +++ b/src/cli.h @@ -17,6 +17,9 @@ #define CLI_EXIT_RUNTIME 1 #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. */ enum cli_action { CLI_ACTION_RUN, /* proceed with the parsed buildfile (todo 16+) */ @@ -28,6 +31,11 @@ enum cli_action { struct cli_opts { const char *program; /* argv[0] as given (or a default) */ 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); diff --git a/src/ext/discovery.c b/src/ext/discovery.c new file mode 100644 index 0000000..cdcdea7 --- /dev/null +++ b/src/ext/discovery.c @@ -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: : ..."). + * + * 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 +#include +#include +#include +#include +#include +#include + +#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='"/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 ":: " 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); +} diff --git a/src/ext/discovery.h b/src/ext/discovery.h new file mode 100644 index 0000000..b9a1061 --- /dev/null +++ b/src/ext/discovery.h @@ -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='"/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: : ..." 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: — checks: () languages: () + * + * 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 +#include + +#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 */ diff --git a/src/main.c b/src/main.c index 52174ee..e05df87 100644 --- a/src/main.c +++ b/src/main.c @@ -7,17 +7,68 @@ * Entrypoint: parse argv via cli.c, dispatch --help/--version/usage * errors, and reserve the `stupidtools ` shape. Buildfile * 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 "ext/abi.h" +#include "ext/discovery.h" +#include "ext/lua.h" + +#include #include +#include + +/* 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 main(int argc, char **argv) { struct cli_opts opts; enum cli_action action; + const char *env; action = cli_parse(argc, argv, &opts); @@ -33,6 +84,14 @@ main(int argc, char **argv) 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) { fprintf(stderr, "%s: error: no build file given\n", opts.program); diff --git a/tests/unit/test_discovery.c b/tests/unit/test_discovery.c new file mode 100644 index 0000000..698843f --- /dev/null +++ b/tests/unit/test_discovery.c @@ -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 +#include +#include +#include +#include +#include + +/* ---------- 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); +}