Initial commit: DPM core library, CLI, bundled info module, and tests
This commit is contained in:
370
src/modules.cpp
Normal file
370
src/modules.cpp
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* @file modules.cpp
|
||||
* @brief Module discovery, load-time validation, routing, enumeration
|
||||
*
|
||||
* Implements the load-time enforcement sequence: core is the sole
|
||||
* authority on module validity. A module is either fully valid or not
|
||||
* loaded — no partial states.
|
||||
*
|
||||
* @copyright Copyright (c) 2026 SILO GROUP LLC
|
||||
* @author Chris Punches <chris.punches@silogroup.org>
|
||||
*
|
||||
* Part of the Dark Horse Linux Package Manager (DPM)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as
|
||||
* published by the Free Software Foundation, either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
#include "internal/context.hpp"
|
||||
|
||||
#include "internal/version.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <dlfcn.h>
|
||||
#include <filesystem>
|
||||
#include <new>
|
||||
#include <set>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
#ifndef DPM_CORE_VERSION_STR
|
||||
#define DPM_CORE_VERSION_STR "0.0.0"
|
||||
#endif
|
||||
|
||||
/* dlclose wrapper referenced from context.cpp so handle release stays
|
||||
in one translation unit with the loader. */
|
||||
void dpm_internal_unload(void* handle)
|
||||
{
|
||||
if (handle) {
|
||||
dlclose(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);
|
||||
|
||||
/** dlsym with dlerror() discipline; returns nullptr on any error. */
|
||||
void* resolve(void* handle, const char* symbol)
|
||||
{
|
||||
dlerror();
|
||||
void* addr = dlsym(handle, symbol);
|
||||
if (dlerror() != nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
return addr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace dpmcore {
|
||||
|
||||
std::unique_ptr<dpm_module> validate_and_load(dpm_ctx* ctx,
|
||||
const std::string& name,
|
||||
std::string& reason)
|
||||
{
|
||||
std::string so_path = ctx->module_path + name + ".so";
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(so_path, ec)) {
|
||||
reason = "not found at " + so_path;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
void* handle = dlopen(so_path.c_str(), RTLD_NOW | RTLD_LOCAL);
|
||||
if (!handle) {
|
||||
const char* err = dlerror();
|
||||
reason = std::string("dlopen failed: ") + (err ? err : "unknown");
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/* Step 1: resolve all reserved contract symbols. */
|
||||
static const char* required[] = {
|
||||
"dpm_module_execute",
|
||||
"dpm_module_version",
|
||||
"dpm_module_description",
|
||||
"dpm_module_core_min",
|
||||
"dpm_module_manifest",
|
||||
};
|
||||
|
||||
std::string missing;
|
||||
for (const char* sym : required) {
|
||||
if (!resolve(handle, sym)) {
|
||||
if (!missing.empty()) {
|
||||
missing += ", ";
|
||||
}
|
||||
missing += sym;
|
||||
}
|
||||
}
|
||||
if (!missing.empty()) {
|
||||
reason = "missing required contract symbols: " + missing;
|
||||
dlclose(handle);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto exec_f = reinterpret_cast<execute_fn>(resolve(handle, "dpm_module_execute"));
|
||||
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: core-minimum handshake. */
|
||||
const char* core_min = core_min_f();
|
||||
long parsed[3];
|
||||
if (!core_min || !parse_version(core_min, parsed)) {
|
||||
reason = "dpm_module_core_min() returned a malformed version";
|
||||
dlclose(handle);
|
||||
return nullptr;
|
||||
}
|
||||
if (compare_versions(core_min, DPM_CORE_VERSION_STR) > 0) {
|
||||
reason = std::string("requires core >= ") + core_min +
|
||||
", running core is " DPM_CORE_VERSION_STR " — update core";
|
||||
dlclose(handle);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/* Step 3: probe the cheap calls. */
|
||||
const char* version = version_f();
|
||||
if (!version || !parse_version(version, parsed)) {
|
||||
reason = "dpm_module_version() returned a malformed version";
|
||||
dlclose(handle);
|
||||
return nullptr;
|
||||
}
|
||||
const char* description = desc_f();
|
||||
if (!description || !*description) {
|
||||
reason = "dpm_module_description() returned nothing";
|
||||
dlclose(handle);
|
||||
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;
|
||||
}
|
||||
|
||||
} // namespace dpmcore
|
||||
|
||||
extern "C" {
|
||||
|
||||
dpm_module* dpm_require(dpm_ctx* ctx, const char* name,
|
||||
const char* min_version)
|
||||
{
|
||||
if (!ctx || !name || !*name) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
long parsed[3];
|
||||
if (min_version && !dpmcore::parse_version(min_version, parsed)) {
|
||||
dpmcore::set_error(ctx, std::string("malformed minimum version: ") +
|
||||
min_version);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
dpm_module* mod = nullptr;
|
||||
auto it = ctx->modules.find(name);
|
||||
if (it != ctx->modules.end()) {
|
||||
mod = it->second.get();
|
||||
} else {
|
||||
std::string reason;
|
||||
auto loaded = dpmcore::validate_and_load(ctx, name, reason);
|
||||
if (!loaded) {
|
||||
dpmcore::set_error(ctx, std::string("module '") + name +
|
||||
"': " + reason);
|
||||
return nullptr;
|
||||
}
|
||||
mod = loaded.get();
|
||||
ctx->modules[name] = std::move(loaded);
|
||||
}
|
||||
|
||||
if (min_version &&
|
||||
dpmcore::compare_versions(mod->version.c_str(), min_version) < 0) {
|
||||
dpmcore::set_error(ctx, std::string("module '") + name +
|
||||
"' is version " + mod->version +
|
||||
", below required minimum " + min_version);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return mod;
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (!ctx || !mod || !mod->execute) {
|
||||
return 1;
|
||||
}
|
||||
return mod->execute(ctx, command, argc, argv);
|
||||
}
|
||||
|
||||
dpm_cursor* dpm_list_modules(dpm_ctx* ctx)
|
||||
{
|
||||
if (!ctx) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::is_directory(ctx->module_path, ec)) {
|
||||
dpmcore::set_error(ctx, "module path is not a readable directory: " +
|
||||
ctx->module_path);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::set<std::string> names;
|
||||
for (const auto& entry : fs::directory_iterator(ctx->module_path, ec)) {
|
||||
if (ec) {
|
||||
break;
|
||||
}
|
||||
if (!entry.is_regular_file() && !entry.is_symlink()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.path().extension() != ".so") {
|
||||
continue;
|
||||
}
|
||||
names.insert(entry.path().stem().string());
|
||||
}
|
||||
|
||||
auto* cur = new (std::nothrow) dpm_cursor;
|
||||
if (!cur) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
for (const std::string& name : names) {
|
||||
dpm_module* mod = nullptr;
|
||||
|
||||
auto it = ctx->modules.find(name);
|
||||
if (it != ctx->modules.end()) {
|
||||
mod = it->second.get();
|
||||
} else {
|
||||
std::string reason;
|
||||
auto loaded = dpmcore::validate_and_load(ctx, name, reason);
|
||||
if (!loaded) {
|
||||
dpm_log(ctx, DPM_LOG_WARN,
|
||||
("module '" + name + "' failed validation: " +
|
||||
reason).c_str());
|
||||
continue;
|
||||
}
|
||||
mod = loaded.get();
|
||||
ctx->modules[name] = std::move(loaded);
|
||||
}
|
||||
|
||||
dpm_module_info info;
|
||||
info.name = mod->name.c_str();
|
||||
info.version = mod->version.c_str();
|
||||
info.description = mod->description.c_str();
|
||||
info.core_min = mod->core_min.c_str();
|
||||
cur->infos.push_back(info);
|
||||
}
|
||||
|
||||
return cur;
|
||||
}
|
||||
|
||||
int dpm_cursor_next(dpm_cursor* cur, dpm_module_info* out)
|
||||
{
|
||||
if (!cur || !out || cur->idx >= cur->infos.size()) {
|
||||
return 1;
|
||||
}
|
||||
*out = cur->infos[cur->idx];
|
||||
cur->idx++;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void dpm_cursor_free(dpm_cursor* cur)
|
||||
{
|
||||
delete cur;
|
||||
}
|
||||
|
||||
} /* extern "C" */
|
||||
Reference in New Issue
Block a user