Document the library's implementation

Every function in context.cpp, modules.cpp, and version.cpp carries a
Doxygen block, and the bodies explain the decisions the code alone does
not show: why dlerror is cleared before dlsym rather than testing the
returned address, what RTLD_NOW and RTLD_LOCAL buy, why missing contract
symbols are collected into one report, why a candidate that fails
validation is logged and skipped instead of failing the listing, and why
version parsing tests the first character itself.

Functions declared in a header carry a brief and implementation prose
rather than a second parameter list; Doxygen was reporting duplicate
documentation sections for each of them.

READMEs in include/internal and src/cli state what those directories
hold.
This commit is contained in:
2026-08-16 00:01:22 -04:00
parent 12a4f68025
commit 303d96ef73
5 changed files with 326 additions and 15 deletions

View File

@@ -0,0 +1,7 @@
# Internal Headers
libdpm-core.so's own headers, used by its sources and never installed.
They declare the types and functions the library's translation units share with each other: the definitions behind the opaque handles `<dpm/core.h>` exposes, and the helpers those sources call across file boundaries.
Nothing here is part of the public interface. The install rule ships `include/dpm/` alone, so a consumer or a module compiles against `<dpm/core.h>` and never sees this directory.

5
src/cli/README.md Normal file
View File

@@ -0,0 +1,5 @@
# CLI
The `dpm` binary's source.
It parses arguments and prints. Every capability it offers comes from a module reached through libdpm-core.so, so its subcommand surface is the set of loadable modules rather than a list built into the tool.

View File

@@ -33,20 +33,49 @@
namespace fs = std::filesystem; namespace fs = std::filesystem;
namespace { namespace {
/** Configuration directory used when no override is supplied. */
const char* DEFAULT_CONFIG_DIR = "/etc/dpm/conf.d/"; const char* DEFAULT_CONFIG_DIR = "/etc/dpm/conf.d/";
/** Module directory used when neither an override nor configuration names one. */
const char* DEFAULT_MODULE_PATH = "/usr/lib/dpm/modules/"; const char* DEFAULT_MODULE_PATH = "/usr/lib/dpm/modules/";
/** Log file used when configuration enables logging without naming a path. */
const char* DEFAULT_LOG_FILE = "/var/log/dpm/dpm.log"; const char* DEFAULT_LOG_FILE = "/var/log/dpm/dpm.log";
/**
* @brief Strips leading and trailing whitespace
*
* Applied to every configuration line, section name, key, and value,
* so that surrounding spaces and the line's terminator never reach
* the configuration store.
*
* @param s The string to trim
* @return The trimmed string; empty when the input is entirely whitespace
*/
std::string trim(const std::string& s) { std::string trim(const std::string& s) {
const char* ws = " \t\r\n\f\v"; const char* ws = " \t\r\n\f\v";
size_t start = s.find_first_not_of(ws); size_t start = s.find_first_not_of(ws);
// Every character is whitespace, so nothing survives trimming.
if (start == std::string::npos) { if (start == std::string::npos) {
return ""; return "";
} }
size_t end = s.find_last_not_of(ws); size_t end = s.find_last_not_of(ws);
return s.substr(start, end - start + 1); return s.substr(start, end - start + 1);
} }
/**
* @brief Appends a trailing slash when one is absent
*
* Directory paths are stored with a trailing slash so that callers
* concatenating a filename onto them produce a valid path without
* checking the separator themselves.
*
* @param s The path to normalize
* @return The path, ending in a slash unless it is empty
*/
std::string with_trailing_slash(std::string s) { std::string with_trailing_slash(std::string s) {
if (!s.empty() && s.back() != '/') { if (!s.empty() && s.back() != '/') {
s += '/'; s += '/';
@@ -54,20 +83,44 @@ namespace {
return s; return s;
} }
/**
* @brief Interprets a configured boolean value
*
* Accepts the spellings a configuration file is likely to carry, in
* any case. A value that matches none of them leaves the setting at
* the caller's fallback rather than guessing.
*
* @param v The configured value
* @param fallback Returned when the value matches no known spelling
* @return The interpreted boolean, or the fallback
*/
bool parse_bool(const std::string& v, bool fallback) { bool parse_bool(const std::string& v, bool fallback) {
// Compare case-insensitively by folding a copy to lowercase.
std::string lower; std::string lower;
for (char c : v) { for (char c : v) {
lower += static_cast<char>(std::tolower(static_cast<unsigned char>(c))); lower += static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
} }
if (lower == "true" || lower == "yes" || lower == "1" || lower == "on") { if (lower == "true" || lower == "yes" || lower == "1" || lower == "on") {
return true; return true;
} }
if (lower == "false" || lower == "no" || lower == "0" || lower == "off") { if (lower == "false" || lower == "no" || lower == "0" || lower == "off") {
return false; return false;
} }
return fallback; return fallback;
} }
/**
* @brief Maps a configured log level name to its numeric level
*
* The comparison is exact and case-sensitive, matching the spelling
* the shipped core.conf uses.
*
* @param v The level name read from configuration
* @param fallback Returned when the name is unrecognized
* @return A DPM_LOG_* value, or the fallback
*/
int level_from_string(const std::string& v, int fallback) { int level_from_string(const std::string& v, int fallback) {
if (v == "FATAL") { return DPM_LOG_FATAL; } if (v == "FATAL") { return DPM_LOG_FATAL; }
if (v == "ERROR") { return DPM_LOG_ERROR; } if (v == "ERROR") { return DPM_LOG_ERROR; }
@@ -77,6 +130,14 @@ namespace {
return fallback; return fallback;
} }
/**
* @brief Names a numeric log level for display
*
* Used to label each message on the console and in the log file.
*
* @param level A DPM_LOG_* value
* @return The level's name, or "UNKNOWN" for a value outside the set
*/
const char* level_name(int level) { const char* level_name(int level) {
switch (level) { switch (level) {
case DPM_LOG_FATAL: return "FATAL"; case DPM_LOG_FATAL: return "FATAL";
@@ -88,6 +149,22 @@ namespace {
} }
} }
/**
* @brief Parses one configuration file into the context's store
*
* Reads a sectioned key/value file. Keys appearing before any
* section header belong to the section named "main". Blank lines,
* lines opening with '#' or ';', and lines carrying no '=' are
* skipped, so a malformed line costs that line alone.
*
* A file that cannot be opened contributes nothing and is not an
* error: configuration is optional, and defaults cover its absence.
*
* @param ctx The context whose configuration store receives the values
* @param file Path of the file to read
* @param module_name The namespace the values are filed under, which
* is the file's name without its extension
*/
void parse_config_file(dpm_ctx* ctx, const fs::path& file, void parse_config_file(dpm_ctx* ctx, const fs::path& file,
const std::string& module_name) { const std::string& module_name) {
std::ifstream in(file); std::ifstream in(file);
@@ -96,21 +173,26 @@ namespace {
} }
std::string line; std::string line;
// Keys read before the first section header belong to "main".
std::string section = "main"; std::string section = "main";
while (std::getline(in, line)) { while (std::getline(in, line)) {
line = trim(line); line = trim(line);
// Blank line or comment.
if (line.empty() || line[0] == '#' || line[0] == ';') { if (line.empty() || line[0] == '#' || line[0] == ';') {
continue; continue;
} }
// Section header: everything after it files under this name.
if (line.front() == '[' && line.back() == ']') { if (line.front() == '[' && line.back() == ']') {
std::string name = trim(line.substr(1, line.length() - 2)); std::string name = trim(line.substr(1, line.length() - 2));
section = name.empty() ? "main" : name; section = name.empty() ? "main" : name;
continue; continue;
} }
// Anything without a separator is not a key/value pair.
size_t eq = line.find('='); size_t eq = line.find('=');
if (eq == std::string::npos) { if (eq == std::string::npos) {
continue; continue;
@@ -118,6 +200,8 @@ namespace {
std::string key = trim(line.substr(0, eq)); std::string key = trim(line.substr(0, eq));
std::string value = trim(line.substr(eq + 1)); std::string value = trim(line.substr(eq + 1));
// A separator with nothing before it names no key.
if (key.empty()) { if (key.empty()) {
continue; continue;
} }
@@ -128,12 +212,28 @@ namespace {
} // namespace } // namespace
namespace dpm_core { namespace dpm_core {
/**
* @brief Records a failure reason on the context
*
* Overwrites whatever reason was recorded previously, so the context
* carries the most recent failure and nothing older.
*/
void set_error(dpm_ctx* ctx, const std::string& msg) { void set_error(dpm_ctx* ctx, const std::string& msg) {
if (ctx) { if (ctx) {
ctx->last_error = msg; ctx->last_error = msg;
} }
} }
/**
* @brief Loads every configuration file in the context's config directory
*
* Each .conf file becomes one configuration namespace named after the
* file, so core.conf fills the "core" namespace and mymodule.conf
* fills "mymodule". Files with any other extension are ignored.
*
* A missing or unreadable directory leaves the store empty, which
* leaves every setting at its default.
*/
void load_config_dir(dpm_ctx* ctx) { void load_config_dir(dpm_ctx* ctx) {
std::error_code ec; std::error_code ec;
if (!fs::is_directory(ctx->config_dir, ec)) { if (!fs::is_directory(ctx->config_dir, ec)) {
@@ -141,6 +241,7 @@ namespace dpm_core {
} }
for (const auto& entry : fs::directory_iterator(ctx->config_dir, ec)) { for (const auto& entry : fs::directory_iterator(ctx->config_dir, ec)) {
// Stop on a directory read error rather than iterating further.
if (ec) { if (ec) {
break; break;
} }
@@ -150,14 +251,31 @@ namespace dpm_core {
if (entry.path().extension() != ".conf") { if (entry.path().extension() != ".conf") {
continue; continue;
} }
// The namespace is the filename without its extension.
parse_config_file(ctx, entry.path(), entry.path().stem().string()); parse_config_file(ctx, entry.path(), entry.path().stem().string());
} }
} }
} // namespace dpm_core } // namespace dpm_core
extern "C" { extern "C" {
/**
* @brief Creates a context
*
* Reads configuration, resolves the module path and target root, and
* initializes logging. Loads no modules.
*
* Each setting is resolved from the most specific source available.
* The module path takes an override first, then the configured
* value, then the built-in default; logging reads configuration and
* then lets an override replace the level.
*
* Explicit overrides are validated before anything is allocated, so
* a caller that names an unusable path gets NULL rather than a
* context that fails later.
*/
dpm_ctx* dpm_open(const dpm_open_overrides* overrides) { dpm_ctx* dpm_open(const dpm_open_overrides* overrides) {
/* Validate explicit overrides before allocating anything. */ // Validate explicit overrides before allocating anything.
if (overrides) { if (overrides) {
if (overrides->config_dir && *overrides->config_dir) { if (overrides->config_dir && *overrides->config_dir) {
std::error_code ec; std::error_code ec;
@@ -175,15 +293,16 @@ extern "C" {
return nullptr; return nullptr;
} }
/* Config directory: override > default. */ // Config directory: override, then default.
ctx->config_dir = DEFAULT_CONFIG_DIR; ctx->config_dir = DEFAULT_CONFIG_DIR;
if (overrides && overrides->config_dir && *overrides->config_dir) { if (overrides && overrides->config_dir && *overrides->config_dir) {
ctx->config_dir = with_trailing_slash(overrides->config_dir); ctx->config_dir = with_trailing_slash(overrides->config_dir);
} }
// Everything below reads configuration, so it loads first.
dpm_core::load_config_dir(ctx); dpm_core::load_config_dir(ctx);
/* Logging: config, then override. */ // Logging: defaults, then configuration, then the override.
ctx->log_level = DPM_LOG_INFO; ctx->log_level = DPM_LOG_INFO;
ctx->write_to_log = false; ctx->write_to_log = false;
ctx->log_file = DEFAULT_LOG_FILE; ctx->log_file = DEFAULT_LOG_FILE;
@@ -201,7 +320,7 @@ extern "C" {
ctx->log_level = overrides->log_level; ctx->log_level = overrides->log_level;
} }
/* Module path: override > config > default. */ // Module path: default, then configuration, then the override.
ctx->module_path = DEFAULT_MODULE_PATH; ctx->module_path = DEFAULT_MODULE_PATH;
if (const char* v = dpm_config_get(ctx, "core", "modules", "path")) { if (const char* v = dpm_config_get(ctx, "core", "modules", "path")) {
ctx->module_path = with_trailing_slash(v); ctx->module_path = with_trailing_slash(v);
@@ -210,7 +329,7 @@ extern "C" {
ctx->module_path = with_trailing_slash(overrides->module_path); ctx->module_path = with_trailing_slash(overrides->module_path);
} }
/* Target root: override > default. */ // Target root: override, then default.
ctx->root = "/"; ctx->root = "/";
if (overrides && overrides->root && *overrides->root) { if (overrides && overrides->root && *overrides->root) {
ctx->root = overrides->root; ctx->root = overrides->root;
@@ -219,20 +338,37 @@ extern "C" {
return ctx; return ctx;
} }
/**
* @brief Releases a context
*
* Unloads every module the context loaded, then frees the context
* itself. Every handle and string the context issued is invalid
* afterwards.
*/
void dpm_close(dpm_ctx* ctx) { void dpm_close(dpm_ctx* ctx) {
if (!ctx) { if (!ctx) {
return; return;
} }
/* dpm_module destructors do not dlclose; do it here so the
registry teardown order is explicit. */ // A dpm_module destructor does not close its handle, so the
// registry is unloaded here and the teardown order stays visible.
for (auto& [name, mod] : ctx->modules) { for (auto& [name, mod] : ctx->modules) {
if (mod->handle) { if (mod->handle) {
dpm_internal_unload(mod->handle); dpm_internal_unload(mod->handle);
} }
} }
delete ctx; delete ctx;
} }
/**
* @brief Reads a configured value
*
* Values are addressed by the three levels the configuration store
* is keyed on: the namespace a file supplied, a section within it,
* and a key within that section. A level that is absent yields NULL,
* which the caller reads as unset.
*/
const char* dpm_config_get(dpm_ctx* ctx, const char* module, const char* dpm_config_get(dpm_ctx* ctx, const char* module,
const char* section, const char* key) { const char* section, const char* key) {
if (!ctx || !module || !section || !key) { if (!ctx || !module || !section || !key) {
@@ -257,13 +393,24 @@ extern "C" {
return key_it->second.c_str(); return key_it->second.c_str();
} }
/**
* @brief Writes a message to the context's log targets
*
* Messages at or below the configured level are written; the rest
* are dropped. Errors and warnings go to stderr so they survive a
* caller redirecting stdout, and the remaining levels go to stdout
* as ordinary output.
*/
void dpm_log(dpm_ctx* ctx, int level, const char* message) { void dpm_log(dpm_ctx* ctx, int level, const char* message) {
if (!ctx || !message) { if (!ctx || !message) {
return; return;
} }
// A level outside the known set is treated as ordinary reporting.
if (level < DPM_LOG_FATAL || level > DPM_LOG_DEBUG) { if (level < DPM_LOG_FATAL || level > DPM_LOG_DEBUG) {
level = DPM_LOG_INFO; level = DPM_LOG_INFO;
} }
if (level > ctx->log_level) { if (level > ctx->log_level) {
return; return;
} }
@@ -284,13 +431,19 @@ extern "C" {
out << stamp << " [" << level_name(level) << "] " out << stamp << " [" << level_name(level) << "] "
<< message << "\n"; << message << "\n";
} else { } else {
/* Log-file trouble never fails the operation; disable and // Log-file trouble never fails the operation; disable the
continue on console alone. */ // file target and carry on writing to the console.
ctx->write_to_log = false; ctx->write_to_log = false;
} }
} }
} }
/**
* @brief Reports the module directory this context resolved
*
* The path was fixed when the context opened, so it reflects the
* override, configured value, or default that won at that point.
*/
const char* dpm_module_path(dpm_ctx* ctx) { const char* dpm_module_path(dpm_ctx* ctx) {
if (!ctx) { if (!ctx) {
return nullptr; return nullptr;
@@ -298,6 +451,13 @@ extern "C" {
return ctx->module_path.c_str(); return ctx->module_path.c_str();
} }
/**
* @brief Reports the most recent failure recorded on the context
*
* The reason is replaced by the next failing call, so it describes
* the most recent failure and nothing earlier. An empty reason reads
* as no failure recorded.
*/
const char* dpm_last_error(dpm_ctx* ctx) { const char* dpm_last_error(dpm_ctx* ctx) {
if (!ctx || ctx->last_error.empty()) { if (!ctx || ctx->last_error.empty()) {
return nullptr; return nullptr;

View File

@@ -39,8 +39,13 @@
namespace fs = std::filesystem; namespace fs = std::filesystem;
/* dlclose wrapper referenced from context.cpp so handle release stays /**
in one translation unit with the loader. */ * @brief Closes a dlopen handle
*
* Called from context.cpp during context teardown. Handle release lives
* here so that dlopen and dlclose stay in the translation unit that owns
* the loader.
*/
void dpm_internal_unload(void* handle) { void dpm_internal_unload(void* handle) {
if (handle) { if (handle) {
dlclose(handle); dlclose(handle);
@@ -48,13 +53,30 @@ void dpm_internal_unload(void* handle) {
} }
namespace { namespace {
/** Signature of a module's command entry point. */
using execute_fn = int (*)(dpm_ctx*, const char*, int, char**); using execute_fn = int (*)(dpm_ctx*, const char*, int, char**);
/** Signature of a module's version and description probes. */
using string_fn = const char* (*)(void); using string_fn = const char* (*)(void);
/** dlsym with dlerror() discipline; returns nullptr on any error. */ /**
* @brief Resolves a symbol, treating any dlerror as failure
*
* A symbol can legitimately resolve to a null address, so the return
* value alone cannot report failure. dlerror is cleared first and
* then checked, which distinguishes the two cases.
*
* @param handle The dlopen handle to search
* @param symbol The symbol name to resolve
* @return The symbol's address, or nullptr when resolution failed
*/
void* resolve(void* handle, const char* symbol) { void* resolve(void* handle, const char* symbol) {
// Clear any error left by an earlier call so the check below
// reflects this resolution alone.
dlerror(); dlerror();
void* addr = dlsym(handle, symbol); void* addr = dlsym(handle, symbol);
if (dlerror() != nullptr) { if (dlerror() != nullptr) {
return nullptr; return nullptr;
} }
@@ -63,9 +85,27 @@ namespace {
} // namespace } // namespace
namespace dpm_core { namespace dpm_core {
/**
* @brief Runs the load-time validation sequence against a module
*
* Opens the module and verifies the contract in two steps: every
* reserved symbol resolves, then the version and description probes
* return well-formed values. A module failing either step is closed
* and refused, so a caller never receives a partially valid module.
*
* The symbols are resolved with RTLD_NOW, so a module carrying an
* unresolvable dependency fails at dlopen rather than at the first
* call into it. RTLD_LOCAL keeps its symbols out of the global
* namespace, which is what prevents one module from reaching another
* except through this library.
*
* Every refusal fills `reason` with a description naming what failed,
* which reaches the caller through dpm_last_error or the log.
*/
std::unique_ptr<dpm_module> validate_and_load(dpm_ctx* ctx, std::unique_ptr<dpm_module> validate_and_load(dpm_ctx* ctx,
const std::string& name, const std::string& name,
std::string& reason) { std::string& reason) {
// A module's name is its filename without the extension.
std::string so_path = ctx->module_path + name + ".so"; std::string so_path = ctx->module_path + name + ".so";
std::error_code ec; std::error_code ec;
@@ -81,13 +121,15 @@ namespace dpm_core {
return nullptr; return nullptr;
} }
/* Step 1: resolve all reserved contract symbols. */ // Step 1: every reserved contract symbol resolves.
static const char* required[] = { static const char* required[] = {
"dpm_module_execute", "dpm_module_execute",
"dpm_module_version", "dpm_module_version",
"dpm_module_description", "dpm_module_description",
}; };
// Collect every missing symbol rather than stopping at the first,
// so the refusal names the whole gap in one report.
std::string missing; std::string missing;
for (const char* sym : required) { for (const char* sym : required) {
if (!resolve(handle, sym)) { if (!resolve(handle, sym)) {
@@ -107,7 +149,9 @@ namespace dpm_core {
auto version_f = reinterpret_cast<string_fn>(resolve(handle, "dpm_module_version")); 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 desc_f = reinterpret_cast<string_fn>(resolve(handle, "dpm_module_description"));
/* Step 2: probe the cheap calls. */ // Step 2: the probes are called immediately, so a module that
// resolves its symbols but answers badly is caught here rather
// than partway through an operation.
long parsed[3]; long parsed[3];
const char* version = version_f(); const char* version = version_f();
if (!version || !parse_version(version, parsed)) { if (!version || !parse_version(version, parsed)) {
@@ -115,6 +159,7 @@ namespace dpm_core {
dlclose(handle); dlclose(handle);
return nullptr; return nullptr;
} }
const char* description = desc_f(); const char* description = desc_f();
if (!description || !*description) { if (!description || !*description) {
reason = "dpm_module_description() returned nothing"; reason = "dpm_module_description() returned nothing";
@@ -122,6 +167,8 @@ namespace dpm_core {
return nullptr; return nullptr;
} }
// Validation passed, so the values read above are recorded and
// the handle becomes the module's for the life of the context.
auto mod = std::make_unique<dpm_module>(); auto mod = std::make_unique<dpm_module>();
mod->name = name; mod->name = name;
mod->handle = handle; mod->handle = handle;
@@ -133,11 +180,23 @@ namespace dpm_core {
} // namespace dpm_core } // namespace dpm_core
extern "C" { extern "C" {
/**
* @brief Loads and returns a validated module
*
* A module is loaded at most once per context: the registry is
* consulted first, and a module already in it is returned as the same
* handle rather than opened a second time.
*
* A failure records its reason on the context before returning,
* leaving the caller a description of what was wrong with the module
* it asked for.
*/
dpm_module* dpm_require(dpm_ctx* ctx, const char* name) { dpm_module* dpm_require(dpm_ctx* ctx, const char* name) {
if (!ctx || !name || !*name) { if (!ctx || !name || !*name) {
return nullptr; return nullptr;
} }
// Already loaded in this context: hand back the same handle.
auto it = ctx->modules.find(name); auto it = ctx->modules.find(name);
if (it != ctx->modules.end()) { if (it != ctx->modules.end()) {
return it->second.get(); return it->second.get();
@@ -151,11 +210,19 @@ extern "C" {
return nullptr; return nullptr;
} }
// The registry takes ownership; the returned pointer stays valid
// until the context closes.
dpm_module* mod = loaded.get(); dpm_module* mod = loaded.get();
ctx->modules[name] = std::move(loaded); ctx->modules[name] = std::move(loaded);
return mod; return mod;
} }
/**
* @brief Reports what the library read from a module at load
*
* The strings point into the module's registry entry, so they remain
* valid as long as the context holds the module.
*/
int dpm_module_info_of(dpm_ctx* ctx, dpm_module* mod, dpm_module_info* out) { int dpm_module_info_of(dpm_ctx* ctx, dpm_module* mod, dpm_module_info* out) {
if (!ctx || !mod || !out) { if (!ctx || !mod || !out) {
return 1; return 1;
@@ -167,6 +234,13 @@ extern "C" {
return 0; return 0;
} }
/**
* @brief Dispatches a command to a module
*
* Hands the call to the module's entry point and returns what it
* returned, adding nothing to the result. This is the only path into
* module code.
*/
int dpm_execute(dpm_ctx* ctx, dpm_module* mod, const char* command, int dpm_execute(dpm_ctx* ctx, dpm_module* mod, const char* command,
int argc, char** argv) { int argc, char** argv) {
if (!ctx || !mod || !mod->execute) { if (!ctx || !mod || !mod->execute) {
@@ -175,6 +249,17 @@ extern "C" {
return mod->execute(ctx, command, argc, argv); return mod->execute(ctx, command, argc, argv);
} }
/**
* @brief Enumerates the valid modules in the module path
*
* Scans the directory and validates every candidate, so a module
* reaching the cursor is one that can actually be run. A candidate
* that fails validation is logged with its reason and left out,
* which keeps a broken module from making the whole listing fail.
*
* Validated modules enter the context's registry as they are found,
* so a later require of the same name reuses this load.
*/
dpm_cursor* dpm_list_modules(dpm_ctx* ctx) { dpm_cursor* dpm_list_modules(dpm_ctx* ctx) {
if (!ctx) { if (!ctx) {
return nullptr; return nullptr;
@@ -187,8 +272,11 @@ extern "C" {
return nullptr; return nullptr;
} }
// A set gives the listing a stable, sorted order and collapses a
// name reachable both directly and through a symlink.
std::set<std::string> names; std::set<std::string> names;
for (const auto& entry : fs::directory_iterator(ctx->module_path, ec)) { for (const auto& entry : fs::directory_iterator(ctx->module_path, ec)) {
// Stop on a directory read error rather than iterating further.
if (ec) { if (ec) {
break; break;
} }
@@ -215,6 +303,9 @@ extern "C" {
} else { } else {
std::string reason; std::string reason;
auto loaded = dpm_core::validate_and_load(ctx, name, reason); auto loaded = dpm_core::validate_and_load(ctx, name, reason);
// A candidate that fails validation is reported and
// skipped, so the listing shows what can be run.
if (!loaded) { if (!loaded) {
dpm_log(ctx, DPM_LOG_WARN, dpm_log(ctx, DPM_LOG_WARN,
("module '" + name + "' failed validation: " + ("module '" + name + "' failed validation: " +
@@ -235,6 +326,13 @@ extern "C" {
return cur; return cur;
} }
/**
* @brief Advances an enumeration cursor
*
* Copies the next entry out and moves the cursor past it. Exhaustion
* and a bad argument both report the same way, so a caller looping
* until nonzero terminates in either case.
*/
int dpm_cursor_next(dpm_cursor* cur, dpm_module_info* out) { int dpm_cursor_next(dpm_cursor* cur, dpm_module_info* out) {
if (!cur || !out || cur->idx >= cur->infos.size()) { if (!cur || !out || cur->idx >= cur->infos.size()) {
return 1; return 1;
@@ -244,6 +342,13 @@ extern "C" {
return 0; return 0;
} }
/**
* @brief Releases an enumeration cursor
*
* The cursor holds copies of the information it reported, so
* releasing it leaves the modules themselves loaded and their
* strings valid.
*/
void dpm_cursor_free(dpm_cursor* cur) { void dpm_cursor_free(dpm_cursor* cur) {
delete cur; delete cur;
} }

View File

@@ -27,14 +27,39 @@
#include <cctype> #include <cctype>
#include <cstdlib> #include <cstdlib>
/** libdpm-core.so's own version. */ /**
* @brief libdpm-core.so's own version
*
* The single place the library's version is written. dpm_core_version()
* reports it, and the build reads it for the artifact naming.
*/
#define DPM_CORE_VERSION_STR "1.0.0" #define DPM_CORE_VERSION_STR "1.0.0"
/**
* @brief Returns libdpm-core.so's own version
*
* Answers from a compiled-in constant, so it needs no context and is
* callable before one is opened. A module reads this to determine for
* itself whether it can work with the library it is running against.
*/
extern "C" const char* dpm_core_version(void) { extern "C" const char* dpm_core_version(void) {
return DPM_CORE_VERSION_STR; return DPM_CORE_VERSION_STR;
} }
namespace dpm_core { namespace dpm_core {
/**
* @brief Parses a strict X.Y.Z version string
*
* Accepts exactly three decimal components separated by single dots,
* with nothing before, between, or after them. Anything looser — a
* leading sign, a missing component, trailing text, a fourth
* component — is rejected, so a module's reported version is either
* a well-formed triple or refused at load.
*
* Each component is read with strtol, which stops at the first
* character it cannot consume; that stopping point is what the
* separator checks below examine.
*/
bool parse_version(const char* s, long out[3]) { bool parse_version(const char* s, long out[3]) {
if (!s || !*s) { if (!s || !*s) {
return false; return false;
@@ -42,22 +67,31 @@ namespace dpm_core {
const char* p = s; const char* p = s;
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
// strtol would skip leading whitespace and accept a sign, so
// the first character is checked directly to reject both.
if (!std::isdigit(static_cast<unsigned char>(*p))) { if (!std::isdigit(static_cast<unsigned char>(*p))) {
return false; return false;
} }
char* end = nullptr; char* end = nullptr;
out[i] = std::strtol(p, &end, 10); out[i] = std::strtol(p, &end, 10);
// A component that overflowed into a negative value is not a
// version number.
if (out[i] < 0) { if (out[i] < 0) {
return false; return false;
} }
if (i < 2) { if (i < 2) {
// The first two components are each followed by a dot,
// and parsing resumes just past it.
if (*end != '.') { if (*end != '.') {
return false; return false;
} }
p = end + 1; p = end + 1;
} else { } else {
// The third component ends the string; trailing text
// means this is not an X.Y.Z version.
if (*end != '\0') { if (*end != '\0') {
return false; return false;
} }