diff --git a/include/internal/README.md b/include/internal/README.md new file mode 100644 index 0000000..23bf36f --- /dev/null +++ b/include/internal/README.md @@ -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 `` 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 `` and never sees this directory. diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 0000000..27f1763 --- /dev/null +++ b/src/cli/README.md @@ -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. diff --git a/src/core/context.cpp b/src/core/context.cpp index 9299105..5b631e3 100644 --- a/src/core/context.cpp +++ b/src/core/context.cpp @@ -33,20 +33,49 @@ namespace fs = std::filesystem; namespace { + /** Configuration directory used when no override is supplied. */ 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/"; + + /** Log file used when configuration enables logging without naming a path. */ 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) { const char* ws = " \t\r\n\f\v"; + size_t start = s.find_first_not_of(ws); + + // Every character is whitespace, so nothing survives trimming. if (start == std::string::npos) { return ""; } + size_t end = s.find_last_not_of(ws); 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) { if (!s.empty() && s.back() != '/') { s += '/'; @@ -54,20 +83,44 @@ namespace { 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) { + // Compare case-insensitively by folding a copy to lowercase. std::string lower; for (char c : v) { lower += static_cast(std::tolower(static_cast(c))); } + if (lower == "true" || lower == "yes" || lower == "1" || lower == "on") { return true; } if (lower == "false" || lower == "no" || lower == "0" || lower == "off") { return false; } + 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) { if (v == "FATAL") { return DPM_LOG_FATAL; } if (v == "ERROR") { return DPM_LOG_ERROR; } @@ -77,6 +130,14 @@ namespace { 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) { switch (level) { 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, const std::string& module_name) { std::ifstream in(file); @@ -96,21 +173,26 @@ namespace { } std::string line; + + // Keys read before the first section header belong to "main". std::string section = "main"; while (std::getline(in, line)) { line = trim(line); + // Blank line or comment. if (line.empty() || line[0] == '#' || line[0] == ';') { continue; } + // Section header: everything after it files under this name. if (line.front() == '[' && line.back() == ']') { std::string name = trim(line.substr(1, line.length() - 2)); section = name.empty() ? "main" : name; continue; } + // Anything without a separator is not a key/value pair. size_t eq = line.find('='); if (eq == std::string::npos) { continue; @@ -118,6 +200,8 @@ namespace { std::string key = trim(line.substr(0, eq)); std::string value = trim(line.substr(eq + 1)); + + // A separator with nothing before it names no key. if (key.empty()) { continue; } @@ -128,12 +212,28 @@ namespace { } // namespace 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) { if (ctx) { 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) { std::error_code 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)) { + // Stop on a directory read error rather than iterating further. if (ec) { break; } @@ -150,14 +251,31 @@ namespace dpm_core { if (entry.path().extension() != ".conf") { continue; } + + // The namespace is the filename without its extension. parse_config_file(ctx, entry.path(), entry.path().stem().string()); } } } // namespace dpm_core 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) { - /* Validate explicit overrides before allocating anything. */ + // Validate explicit overrides before allocating anything. if (overrides) { if (overrides->config_dir && *overrides->config_dir) { std::error_code ec; @@ -175,15 +293,16 @@ extern "C" { return nullptr; } - /* Config directory: override > default. */ + // Config directory: override, then default. ctx->config_dir = DEFAULT_CONFIG_DIR; if (overrides && overrides->config_dir && *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); - /* Logging: config, then override. */ + // Logging: defaults, then configuration, then the override. ctx->log_level = DPM_LOG_INFO; ctx->write_to_log = false; ctx->log_file = DEFAULT_LOG_FILE; @@ -201,7 +320,7 @@ extern "C" { 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; if (const char* v = dpm_config_get(ctx, "core", "modules", "path")) { ctx->module_path = with_trailing_slash(v); @@ -210,7 +329,7 @@ extern "C" { ctx->module_path = with_trailing_slash(overrides->module_path); } - /* Target root: override > default. */ + // Target root: override, then default. ctx->root = "/"; if (overrides && overrides->root && *overrides->root) { ctx->root = overrides->root; @@ -219,20 +338,37 @@ extern "C" { 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) { if (!ctx) { 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) { if (mod->handle) { dpm_internal_unload(mod->handle); } } + 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* section, const char* key) { if (!ctx || !module || !section || !key) { @@ -257,13 +393,24 @@ extern "C" { 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) { if (!ctx || !message) { return; } + + // A level outside the known set is treated as ordinary reporting. if (level < DPM_LOG_FATAL || level > DPM_LOG_DEBUG) { level = DPM_LOG_INFO; } + if (level > ctx->log_level) { return; } @@ -284,13 +431,19 @@ extern "C" { out << stamp << " [" << level_name(level) << "] " << message << "\n"; } else { - /* Log-file trouble never fails the operation; disable and - continue on console alone. */ + // Log-file trouble never fails the operation; disable the + // file target and carry on writing to the console. 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) { if (!ctx) { return nullptr; @@ -298,6 +451,13 @@ extern "C" { 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) { if (!ctx || ctx->last_error.empty()) { return nullptr; diff --git a/src/core/modules.cpp b/src/core/modules.cpp index b0bbf98..98da1f4 100644 --- a/src/core/modules.cpp +++ b/src/core/modules.cpp @@ -39,8 +39,13 @@ 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) { if (handle) { dlclose(handle); @@ -48,13 +53,30 @@ void dpm_internal_unload(void* handle) { } namespace { + /** Signature of a module's command entry point. */ 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); - /** 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) { + // Clear any error left by an earlier call so the check below + // reflects this resolution alone. dlerror(); + void* addr = dlsym(handle, symbol); + if (dlerror() != nullptr) { return nullptr; } @@ -63,9 +85,27 @@ namespace { } // namespace 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 validate_and_load(dpm_ctx* ctx, const std::string& name, std::string& reason) { + // A module's name is its filename without the extension. std::string so_path = ctx->module_path + name + ".so"; std::error_code ec; @@ -81,13 +121,15 @@ namespace dpm_core { return nullptr; } - /* Step 1: resolve all reserved contract symbols. */ + // Step 1: every reserved contract symbol resolves. static const char* required[] = { "dpm_module_execute", "dpm_module_version", "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; for (const char* sym : required) { if (!resolve(handle, sym)) { @@ -107,7 +149,9 @@ namespace dpm_core { auto version_f = reinterpret_cast(resolve(handle, "dpm_module_version")); auto desc_f = reinterpret_cast(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]; const char* version = version_f(); if (!version || !parse_version(version, parsed)) { @@ -115,6 +159,7 @@ namespace dpm_core { dlclose(handle); return nullptr; } + const char* description = desc_f(); if (!description || !*description) { reason = "dpm_module_description() returned nothing"; @@ -122,6 +167,8 @@ namespace dpm_core { 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(); mod->name = name; mod->handle = handle; @@ -133,11 +180,23 @@ namespace dpm_core { } // namespace dpm_core 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) { if (!ctx || !name || !*name) { return nullptr; } + // Already loaded in this context: hand back the same handle. auto it = ctx->modules.find(name); if (it != ctx->modules.end()) { return it->second.get(); @@ -151,11 +210,19 @@ extern "C" { return nullptr; } + // The registry takes ownership; the returned pointer stays valid + // until the context closes. dpm_module* mod = loaded.get(); ctx->modules[name] = std::move(loaded); 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) { if (!ctx || !mod || !out) { return 1; @@ -167,6 +234,13 @@ extern "C" { 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 argc, char** argv) { if (!ctx || !mod || !mod->execute) { @@ -175,6 +249,17 @@ extern "C" { 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) { if (!ctx) { return nullptr; @@ -187,8 +272,11 @@ extern "C" { return nullptr; } + // A set gives the listing a stable, sorted order and collapses a + // name reachable both directly and through a symlink. std::set names; for (const auto& entry : fs::directory_iterator(ctx->module_path, ec)) { + // Stop on a directory read error rather than iterating further. if (ec) { break; } @@ -215,6 +303,9 @@ extern "C" { } else { std::string 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) { dpm_log(ctx, DPM_LOG_WARN, ("module '" + name + "' failed validation: " + @@ -235,6 +326,13 @@ extern "C" { 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) { if (!cur || !out || cur->idx >= cur->infos.size()) { return 1; @@ -244,6 +342,13 @@ extern "C" { 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) { delete cur; } diff --git a/src/core/version.cpp b/src/core/version.cpp index a6839cb..89beed7 100644 --- a/src/core/version.cpp +++ b/src/core/version.cpp @@ -27,14 +27,39 @@ #include #include -/** 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" +/** + * @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) { return DPM_CORE_VERSION_STR; } 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]) { if (!s || !*s) { return false; @@ -42,22 +67,31 @@ namespace dpm_core { const char* p = s; 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(*p))) { return false; } char* end = nullptr; out[i] = std::strtol(p, &end, 10); + + // A component that overflowed into a negative value is not a + // version number. if (out[i] < 0) { return false; } if (i < 2) { + // The first two components are each followed by a dot, + // and parsing resumes just past it. if (*end != '.') { return false; } p = end + 1; } else { + // The third component ends the string; trailing text + // means this is not an X.Y.Z version. if (*end != '\0') { return false; }