Route all module interaction through dispatch

A module is addressed by name and command string, and nothing else.
Typed API access handed a caller a pointer into the callee's function
table, which meant compiling against that module's struct layout — a
build-time dependency between modules that the design does not permit.
Removing it also removes the manifest, the table magic constant, and
the table size field, which existed only to describe and validate
those tables.

Load validation is now three steps: reserved contract symbols resolve,
the minimum-version handshake passes, and the version and description
probes return well-formed values. The contract is four reserved
symbols, and a module's interface is the command vocabulary it
documents.

Documentation is brought in line, and artifacts are named exactly:
libdpm-core.so for the library, <dpm/core.h> for the header, the dpm
binary for the command-line tool.
This commit is contained in:
2026-08-15 02:15:18 -04:00
parent 267529bee3
commit 97b39cac6c
17 changed files with 263 additions and 702 deletions

View File

@@ -2,10 +2,10 @@
* @file info.cpp
* @brief The info module: contract symbols and command routing
*
* Bundles with the dpm binary and libdpm-core; used for testing and
* reporting functionality of libdpm-core. Implements the full DPM
* module contract: the five reserved
* symbols plus a manifest-declared API table.
* Bundles with the dpm binary and libdpm-core.so; used for testing and
* reporting functionality of libdpm-core.so. Implements the full DPM
* module contract: the four reserved symbols, with every capability
* reached by command string through the entry point.
*
* @copyright Copyright (c) 2026 SILO GROUP LLC
* @author Chris Punches <chris.punches@silogroup.org>
@@ -30,42 +30,6 @@
#define INFO_MODULE_VERSION "0.1.0"
#define INFO_CORE_MIN "0.1.0"
/* ------------------------------------------------------------------ */
/* info_api_v1 — typed access surface */
/* ------------------------------------------------------------------ */
/**
* @brief Version 1 API table for the info module
*
* Lets consumers exercise the typed-access path (require → get_api
* → call) end to end, which is part of this module's purpose of
* testing libdpm-core functionality.
*/
struct info_api_v1_s {
dpm_api_table_header hdr;
const char* (*version)(void); /**< info module version */
const char* (*description)(void); /**< info module description */
};
static const char* api_version(void)
{
return INFO_MODULE_VERSION;
}
static const char* api_description(void)
{
return "Reports and tests libdpm-core functionality.";
}
extern "C" {
extern const struct info_api_v1_s info_api_v1;
const struct info_api_v1_s info_api_v1 = {
{ DPM_API_TABLE_MAGIC, sizeof(struct info_api_v1_s) },
api_version,
api_description,
};
}
/* ------------------------------------------------------------------ */
/* Reserved contract symbols */
/* ------------------------------------------------------------------ */
@@ -95,19 +59,7 @@ extern "C" const char* dpm_module_core_min(void)
}
/**
* @brief Declares the module's entire functional surface
*/
extern "C" const dpm_manifest* dpm_module_manifest(void)
{
static const dpm_manifest_entry entries[] = {
{ "info", 1, "info_api_v1" },
};
static const dpm_manifest manifest = { 1, entries };
return &manifest;
}
/**
* @brief Generic command entry point
* @brief Command entry point
*
* Routes the command to the appropriate handler. NULL or empty
* command behaves as help.

View File

@@ -1,27 +1,18 @@
/*
* libdpm-core.map — the linker version script for libdpm-core.so
* libdpm-core.map — the export list for libdpm-core.so
*
* Passed to the linker with --version-script. It does two things: it
* pins the library's exported symbol list, and it stamps every exported
* symbol with the version node named below.
* Passed to the linker with --version-script. Names under `global` are
* exported; `local: *` hides everything else, so the public C API is the
* only surface a consumer or a loaded module can bind to.
*
* What goes in `global`: every function declared in the public header,
* include/dpm/core.h, and nothing else. The library compiles with
* hidden default visibility, so `local: *` is what the implementation
* already gets; listing it here makes the export set explicit and fails
* the link loudly if the two ever disagree.
* `global` is every function in include/dpm/core.h and nothing else. A
* new public function is added here in the commit that adds it to the
* header.
*
* Changing it: adding a public function means adding its name here in
* the same commit that adds it to the header. Removing or renaming one
* breaks every consumer already linked against it, so a removal ships
* as a new node above the existing one — binaries already linked
* against the older node keep resolving it while new links bind to the
* newer one.
*
* The node name carries the ABI generation. Newest node first, retired
* generations below it. DPM_CORE_0.1 at the bottom was never released —
* it is there so the next break follows its shape instead of inventing
* one.
* The node name stamps each symbol (dpm_open@@DPM_CORE_1.0), so a break
* can ship as a new node while already-linked binaries keep resolving
* the old one. Newest node first. DPM_CORE_0.1 was never released; it is
* the shape a retired node takes.
*/
DPM_CORE_1.0 {
global:
@@ -29,7 +20,6 @@ DPM_CORE_1.0 {
dpm_close;
dpm_require;
dpm_module_info_of;
dpm_get_api;
dpm_execute;
dpm_list_modules;
dpm_cursor_next;

View File

@@ -2,9 +2,12 @@
* @file modules.cpp
* @brief Module discovery, load-time validation, routing, enumeration
*
* Implements the load-time enforcement sequence: libdpm-core is the sole
* authority on module validity. A module is either fully valid or not
* loaded — no partial states.
* Implements the load-time enforcement sequence: libdpm-core.so is the
* sole authority on module validity. A module is either fully valid or
* not loaded — no partial states.
*
* Dispatch through dpm_execute is the only path into module code, so a
* caller never holds compile-time knowledge of what it is calling.
*
* @copyright Copyright (c) 2026 SILO GROUP LLC
* @author Chris Punches <chris.punches@silogroup.org>
@@ -51,9 +54,8 @@ void dpm_internal_unload(void* handle)
namespace {
using execute_fn = int (*)(dpm_ctx*, const char*, int, char**);
using string_fn = const char* (*)(void);
using manifest_fn = const dpm_manifest* (*)(void);
using execute_fn = int (*)(dpm_ctx*, const char*, int, char**);
using string_fn = const char* (*)(void);
/** dlsym with dlerror() discipline; returns nullptr on any error. */
void* resolve(void* handle, const char* symbol)
@@ -95,7 +97,6 @@ std::unique_ptr<dpm_module> validate_and_load(dpm_ctx* ctx,
"dpm_module_version",
"dpm_module_description",
"dpm_module_core_min",
"dpm_module_manifest",
};
std::string missing;
@@ -117,7 +118,6 @@ std::unique_ptr<dpm_module> validate_and_load(dpm_ctx* ctx,
auto version_f = reinterpret_cast<string_fn>(resolve(handle, "dpm_module_version"));
auto desc_f = reinterpret_cast<string_fn>(resolve(handle, "dpm_module_description"));
auto core_min_f = reinterpret_cast<string_fn>(resolve(handle, "dpm_module_core_min"));
auto manifest_f = reinterpret_cast<manifest_fn>(resolve(handle, "dpm_module_manifest"));
/* Step 2: minimum-version handshake. */
const char* core_min = core_min_f();
@@ -149,62 +149,12 @@ std::unique_ptr<dpm_module> validate_and_load(dpm_ctx* ctx,
return nullptr;
}
/* Step 4: cross-check the manifest. */
const dpm_manifest* manifest = manifest_f();
if (!manifest) {
reason = "dpm_module_manifest() returned NULL";
dlclose(handle);
return nullptr;
}
if (manifest->count > 0 && !manifest->entries) {
reason = "manifest declares entries but the entry table is NULL";
dlclose(handle);
return nullptr;
}
for (uint32_t i = 0; i < manifest->count; i++) {
const dpm_manifest_entry& entry = manifest->entries[i];
if (!entry.api_name || !*entry.api_name ||
!entry.symbol || !*entry.symbol || entry.table_version < 1) {
reason = "manifest entry " + std::to_string(i) + " is malformed";
dlclose(handle);
return nullptr;
}
void* table = resolve(handle, entry.symbol);
if (!table) {
reason = std::string("manifest declares API '") + entry.api_name +
"' v" + std::to_string(entry.table_version) +
"' at symbol '" + entry.symbol +
"' but the symbol does not resolve";
dlclose(handle);
return nullptr;
}
/* Step 5: table sanity. */
auto* header = static_cast<const dpm_api_table_header*>(table);
if (header->magic != DPM_API_TABLE_MAGIC) {
reason = std::string("API table '") + entry.symbol +
"' has a bad magic constant";
dlclose(handle);
return nullptr;
}
if (header->size < sizeof(dpm_api_table_header)) {
reason = std::string("API table '") + entry.symbol +
"' reports an impossible size";
dlclose(handle);
return nullptr;
}
}
auto mod = std::make_unique<dpm_module>();
mod->name = name;
mod->handle = handle;
mod->version = version;
mod->description = description;
mod->core_min = core_min;
mod->manifest = manifest;
mod->execute = exec_f;
return mod;
}
@@ -250,34 +200,6 @@ int dpm_module_info_of(dpm_ctx* ctx, dpm_module* mod, dpm_module_info* out)
return 0;
}
const void* dpm_get_api(dpm_ctx* ctx, dpm_module* mod,
const char* api_name, int table_version)
{
if (!ctx || !mod || !api_name) {
return nullptr;
}
for (uint32_t i = 0; i < mod->manifest->count; i++) {
const dpm_manifest_entry& entry = mod->manifest->entries[i];
if (entry.table_version == table_version &&
std::string(entry.api_name) == api_name) {
dlerror();
void* table = dlsym(mod->handle, entry.symbol);
if (dlerror() != nullptr || !table) {
dpmcore::set_error(ctx, std::string("API table symbol '") +
entry.symbol + "' vanished after load");
return nullptr;
}
return table;
}
}
dpmcore::set_error(ctx, std::string("module '") + mod->name +
"' does not provide API '" + api_name +
"' v" + std::to_string(table_version));
return nullptr;
}
int dpm_execute(dpm_ctx* ctx, dpm_module* mod, const char* command,
int argc, char** argv)
{