Module records and aliases

Installation records what a module reports about itself. dpm_install_module
opens a module once, writes its version and description to a .meta file in
/var/lib/dpm/metadata/, and records the alternate names it declared in
modules.aliases beside it. dpm_uninstall_module removes both, leaving the
module file in place.

dpm_list_modules reads those records and opens no module. A module with no
record lists with a version of <uninstalled> and still loads when a caller
names it. An unreadable or absent metadata directory costs the listing its
detail and costs nothing else.

Aliases give a module alternate names, declared through the new
dpm_module_aliases contract symbol or added with dpm_add_module_alias. A
name is recorded once: one already serving as an alias, or belonging to an
installed module, is refused rather than repointed. dpm_require matches a
name against the installed modules, then the alias table, then the module
path.

The metadata directory is a fifth override field and the -M flag, and
[modules] metadata in core.conf.

The test suite is four binaries covering context, modules, records, and
aliases, each a ctest case of its own, alongside the CLI cases.
This commit is contained in:
2026-08-24 03:51:39 -04:00
parent 71d409b5c7
commit 0291e61fd8
32 changed files with 2147 additions and 290 deletions

View File

@@ -21,9 +21,11 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
# libdpm-core.so — the library (C ABI) # libdpm-core.so — the library (C ABI)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
add_library(dpm-core SHARED add_library(dpm-core SHARED
src/core/aliases.cpp
src/core/conf.cpp src/core/conf.cpp
src/core/context.cpp src/core/context.cpp
src/core/logging.cpp src/core/logging.cpp
src/core/metadata.cpp
src/core/modules.cpp src/core/modules.cpp
src/core/sanitizers.cpp src/core/sanitizers.cpp
src/core/version.cpp src/core/version.cpp

View File

@@ -5,3 +5,4 @@ log_file = /var/log/dpm/dpm.log
[modules] [modules]
path = /usr/lib/dpm/modules path = /usr/lib/dpm/modules
metadata = /var/lib/dpm/metadata

View File

@@ -37,12 +37,13 @@ dpm_open_overrides ov = {
"/path/to/conf.d", "/path/to/conf.d",
"/path/to/modules", "/path/to/modules",
"/path/to/root", "/path/to/root",
-1 -1,
"/path/to/metadata"
}; };
dpm_ctx* ctx = dpm_open(&ov); dpm_ctx* ctx = dpm_open(&ov);
``` ```
Leaving `config_dir` NULL selects `/etc/dpm/conf.d/`; leaving `module_path` NULL selects the configured value and then the built-in default; leaving `root` NULL selects `/`; a `log_level` of -1 takes the configured value. Leaving `config_dir` NULL selects `/etc/dpm/conf.d/`; leaving `module_path` NULL selects the configured value and then the built-in default; leaving `root` NULL selects `/`; a `log_level` of -1 takes the configured value; leaving `metadata_dir` NULL selects the configured value and then `/var/lib/dpm/metadata/`.
The root override is what makes chroot builds, image assembly, and sysroot management work: package operations act on the given tree instead of the running system. Multiple simultaneous contexts with different roots are legal. The root override is what makes chroot builds, image assembly, and sysroot management work: package operations act on the given tree instead of the running system. Multiple simultaneous contexts with different roots are legal.
@@ -56,6 +57,8 @@ dpm_module* mod = dpm_require(ctx, "mymodule");
libdpm-core.so validates the module completely at load; a handle is returned only for a fully valid module. NULL means the module is absent or invalid — `dpm_get_last_error`(ctx) carries the precise reason. Modules load at most once per context; repeated calls return the same handle. libdpm-core.so validates the module completely at load; a handle is returned only for a fully valid module. NULL means the module is absent or invalid — `dpm_get_last_error`(ctx) carries the precise reason. Modules load at most once per context; repeated calls return the same handle.
The name is matched against the installed modules first, then the alias table, then the module path itself, so a module answers to its own name and to any alias recorded for it. A name that reaches the module path belongs to a module nobody has installed; that is logged and the module loads anyway.
`dpm_get_module_info` reports what the library saw in the loaded module: `dpm_get_module_info` reports what the library saw in the loaded module:
``` ```
@@ -86,17 +89,23 @@ while (dpm_cursor_next(cur, &info) == 0) {
dpm_cursor_free(cur); dpm_cursor_free(cur);
``` ```
The cursor covers every valid module in the module path; invalid candidates are excluded and logged. The cursor covers every module in the module path, reported from what installation recorded. An installed module carries its recorded version and description; one with no record carries a version of `<uninstalled>`. No module is opened, so enumerating executes nothing.
## Records and Aliases
`dpm_install_module` loads a module once, writes down what it reports, and records the alternate names it declared. `dpm_uninstall_module` removes both, leaving the module file in place. `dpm_add_module_alias`, `dpm_remove_module_alias`, and `dpm_list_module_aliases` manage alternate names directly; the alias cursor is advanced with `dpm_alias_cursor_next` and released with `dpm_alias_cursor_free`.
A name is recorded once. Adding one that is already an alias, or that belongs to an installed module, is refused rather than repointed.
## Services ## Services
- `dpm_core_version`()** — the library version; callable without a context. - `dpm_core_version`() — the library version; callable without a context.
- `dpm_get_module_info`(ctx, mod, out)** — the name, version, and description read from a loaded module. - `dpm_get_module_info`(ctx, mod, out) — the name, version, and description read from a loaded module.
- `dpm_config_get`(ctx, module, section, key)** — a value from a module's configuration namespace, or NULL if unset. - `dpm_config_get`(ctx, module, section, key) — a value from a module's configuration namespace, or NULL if unset.
- `dpm_log`(ctx, level, message)** — writes to the context's configured log targets; levels are `DPM_LOG_FATAL` through `DPM_LOG_DEBUG`. - `dpm_log`(ctx, level, message) — writes to the context's configured log targets; levels are `DPM_LOG_FATAL` through `DPM_LOG_DEBUG`.
- `dpm_get_resolved_module_path`(ctx)** — the resolved module directory. - `dpm_get_resolved_module_path`(ctx) — the resolved module directory.
- `dpm_set_last_error`(ctx, msg) — records a failure reason on the context; what a module calls to explain a nonzero return. - `dpm_set_last_error`(ctx, msg) — records a failure reason on the context; what a module calls to explain a nonzero return.
- `dpm_get_last_error`(ctx)** — a human-readable description of the most recent failure on the context, or NULL. - `dpm_get_last_error`(ctx) — a human-readable description of the most recent failure on the context, or NULL.
## Ownership and Errors ## Ownership and Errors

View File

@@ -43,6 +43,7 @@ It provides:
- **Validation**: the full load-time contract enforcement described below. libdpm-core.so is the sole authority on what a valid module is; the contract definition lives inside the library as data. There is no SDK package — the interface is specified by this document and enforced by the library's validator. - **Validation**: the full load-time contract enforcement described below. libdpm-core.so is the sole authority on what a valid module is; the contract definition lives inside the library as data. There is no SDK package — the interface is specified by this document and enforced by the library's validator.
- **Routing**: dispatch a command string with arguments to a named module. This is the only path into module code, and it is the same path for the `dpm` binary, for a build system, and for a module calling a peer. - **Routing**: dispatch a command string with arguments to a named module. This is the only path into module code, and it is the same path for the `dpm` binary, for a build system, and for a module calling a peer.
- **Version reporting**: require resolves a module by name, loads it, and returns a handle, or reports precisely why it can't. The loaded module's version is readable from the handle, for the caller to judge. - **Version reporting**: require resolves a module by name, loads it, and returns a handle, or reports precisely why it can't. The loaded module's version is readable from the handle, for the caller to judge.
- **Records**: what an installed module reports about itself, written once at installation into `/var/lib/dpm/metadata/` and read by every listing, so describing the modules on a system executes none of them. The same directory holds the alias table, which gives a module alternate names.
- **Common services**: configuration access (per-module namespaces from `/etc/dpm/conf.d/`), logging, module-path queries. - **Common services**: configuration access (per-module namespaces from `/etc/dpm/conf.d/`), logging, module-path queries.
## The C API ## The C API
@@ -52,7 +53,7 @@ All functions are extern "C". All returned strings are owned by the library (or
### Context Lifecycle ### Context Lifecycle
`dpm_ctx* dpm_open(const dpm_open_overrides* overrides)` `dpm_ctx* dpm_open(const dpm_open_overrides* overrides)`
Creates a context. Reads configuration from `/etc/dpm/conf.d/` (or the config directory named in overrides), resolves the module path (overrides take precedence over config, config over the built-in default), and initializes logging per configuration. Performs no module loading. Returns NULL only on allocation failure or an unreadable/invalid explicit override; a missing config directory is not an error — defaults apply. `overrides` may be NULL, and may specify: config directory, module path, target root (for chroot/image/sysroot operation), and log level. Multiple simultaneous contexts with different roots are legal. Creates a context. Reads configuration from `/etc/dpm/conf.d/` (or the config directory named in overrides), resolves the module path (overrides take precedence over config, config over the built-in default), and initializes logging per configuration. Performs no module loading. Returns NULL only on allocation failure or an unreadable/invalid explicit override; a missing config directory is not an error — defaults apply. `overrides` may be NULL, and may specify: config directory, module path, target root (for chroot/image/sysroot operation), log level, and metadata directory. Multiple simultaneous contexts with different roots are legal.
`void dpm_close(dpm_ctx* ctx)` `void dpm_close(dpm_ctx* ctx)`
Releases the context: unloads every module handle it issued, closes log targets, frees all memory owned by the context. All handles and strings obtained through the context are invalid after this call. NULL is a no-op. Releases the context: unloads every module handle it issued, closes log targets, frees all memory owned by the context. All handles and strings obtained through the context are invalid after this call. NULL is a no-op.
@@ -70,10 +71,28 @@ Dispatch: invokes the module's `dpm_module_execute` with the context, `command`,
This is the entire path into module code. A caller addresses a module by name and a capability by command string, so it holds no compile-time knowledge of the module it is calling — no headers, no struct layouts, no symbols. That is what allows a module to be developed, built, and tested with no peer present. This is the entire path into module code. A caller addresses a module by name and a capability by command string, so it holds no compile-time knowledge of the module it is calling — no headers, no struct layouts, no symbols. That is what allows a module to be developed, built, and tested with no peer present.
### Installation
Installation is what lets libdpm-core.so describe a module without opening it. It loads the module once, writes down the version, description, and aliases the module reported, and from then on every listing reads that record. It is the only operation that opens a module for bookkeeping rather than to run it.
Uninstallation removes the record and the module's aliases, leaving the `.so` in place. A module that has never been installed, or whose record has been removed, is reported as uninstalled and still loads when a caller requires it by name. Bookkeeping describes the system; it never decides what may run.
Aliases give a module alternate names. A module declares its own, an operator adds more, and once recorded the two are the same thing. A name is recorded once: adding a name already serving as an alias, or already belonging to an installed module, is refused rather than repointed, so an existing route to a module is never taken over by a later one.
### Name Resolution
`dpm_require` resolves a name in three steps, taking the first that answers:
1. An installed module of that name, from the records
2. An alias of that name, from the alias table
3. `<module path>/<name>.so`
Reaching the third step means the module is not installed. That is logged and the load proceeds, so a module placed by hand runs without an installation step standing between an operator and the system.
### Enumeration ### Enumeration
`dpm_cursor* dpm_list_modules(dpm_ctx* ctx)` `dpm_cursor* dpm_list_modules(dpm_ctx* ctx)`
Scans the module path and returns a cursor over all *valid* modules (each candidate .so is validated on first scan; failures are logged and excluded). Returns NULL on an unreadable module path. Returns a cursor over the modules in the module path, reported from their records. An installed module carries its recorded version and description; a module with no record carries a version of `<uninstalled>` and an empty description. No module is opened, so listing the modules on a system executes none of them. Returns NULL on an unreadable module path.
`int dpm_cursor_next(dpm_cursor* cur, dpm_module_info* out)` `int dpm_cursor_next(dpm_cursor* cur, dpm_module_info* out)`
Advances the cursor. Fills `out` with the next module's name, version, and description (string pointers valid until context close). Returns 0 and fills `out` while entries remain; returns nonzero at end. Advances the cursor. Fills `out` with the next module's name, version, and description (string pointers valid until context close). Returns 0 and fills `out` while entries remain; returns nonzero at end.
@@ -103,7 +122,7 @@ Returns a human-readable description of the most recent failure recorded on this
## Module Contract ## Module Contract
A module is one .so in the module directory. It includes `<dpm/core.h>`, links `-ldpm-core`, and exports the following reserved symbols as extern "C". Returned strings are static or module-owned, non-NULL, and valid for the lifetime of the loaded module; the library and consumers never free them. A module is one .so in the module directory. It includes `<dpm/core.h>`, links `-ldpm-core`, and exports the following four reserved symbols as extern "C". Returned strings are static or module-owned, non-NULL, and valid for the lifetime of the loaded module; the library and consumers never free them.
`int dpm_module_execute(dpm_ctx* ctx, const char* command, int argc, char** argv)` `int dpm_module_execute(dpm_ctx* ctx, const char* command, int argc, char** argv)`
The module's command entry point, and the only entry through which it performs work. `ctx` is the host context that dispatched the call — the module reaches every service (`dpm_log`, `dpm_config_get`, `dpm_get_resolved_module_path`, ...) through it, and reaches peer modules through it as well. `command` is the subcommand name (equal to argv[0] when argc > 0); argc/argv are the remaining CLI-style arguments. NULL or empty `command` must behave as the module's help command. Returns 0 on success, nonzero on failure. It must be callable immediately after load with no other setup. The module's command entry point, and the only entry through which it performs work. `ctx` is the host context that dispatched the call — the module reaches every service (`dpm_log`, `dpm_config_get`, `dpm_get_resolved_module_path`, ...) through it, and reaches peer modules through it as well. `command` is the subcommand name (equal to argv[0] when argc > 0); argc/argv are the remaining CLI-style arguments. NULL or empty `command` must behave as the module's help command. Returns 0 on success, nonzero on failure. It must be callable immediately after load with no other setup.
@@ -114,6 +133,9 @@ Returns the module's own version as an X.Y.Z string. Must be constant for the li
`const char* dpm_module_description(void)` `const char* dpm_module_description(void)`
Returns a one-line human-readable description, used in module listings. Returns a one-line human-readable description, used in module listings.
`const char* dpm_module_aliases(void)`
Returns a comma-separated list of alternate names the module answers to, or NULL to declare none. NULL is an answer; the symbol's absence is a contract violation and the module is refused. libdpm-core.so reads the list at load and records the names at installation.
**Version compatibility with the library is the module's own to determine.** A module is built against the system-installed `libdpm-core.so` and is responsible for being correct against it. Where it needs to act on what it is running under, `dpm_core_version()` reports the running version and the module decides what to do with that. **Version compatibility with the library is the module's own to determine.** A module is built against the system-installed `libdpm-core.so` and is responsible for being correct against it. Where it needs to act on what it is running under, `dpm_core_version()` reports the running version and the module decides what to do with that.
**Symbol naming**: functional exports are prefixed with the module's name (raw_\*, pkg_\*); the dpm_ prefix is reserved for the contract and for libdpm-core.so. **Symbol naming**: functional exports are prefixed with the module's name (raw_\*, pkg_\*); the dpm_ prefix is reserved for the contract and for libdpm-core.so.
@@ -124,8 +146,8 @@ Returns a one-line human-readable description, used in module listings.
libdpm-core.so is the sole authority on module validity; the contract above is enforced by its validator, not by any SDK. Validation is all-or-nothing; a module is registered only after passing every step: libdpm-core.so is the sole authority on module validity; the contract above is enforced by its validator, not by any SDK. Validation is all-or-nothing; a module is registered only after passing every step:
1. **Resolve all reserved contract symbols.** Any missing → refuse, log the exact list, `dlclose`. 1. **Resolve all four reserved contract symbols.** Any missing → refuse, log the exact list, `dlclose`.
2. **Probe the cheap calls.** `dpm_module_version`() and `dpm_module_description`() are invoked immediately; NULL or malformed returns → refuse. 2. **Probe the cheap calls.** `dpm_module_version`() and `dpm_module_description`() are invoked immediately; NULL or malformed returns → refuse. `dpm_module_aliases`() is invoked and its answer recorded; NULL declares no alternate names and is accepted.
Failures happen at install/load time, loudly and itemized. Consumers never receive a partially valid module: if a handle was handed out, the contract already validated. Failures happen at install/load time, loudly and itemized. Consumers never receive a partially valid module: if a handle was handed out, the contract already validated.
@@ -211,6 +233,8 @@ Terminology: **the `dpm` binary** names the command-line tool; **libdpm-core.so*
| `core.h` | `/usr/include/dpm/core.h` | | `core.h` | `/usr/include/dpm/core.h` |
| `info.so` | `/usr/lib/dpm/modules/info.so` | | `info.so` | `/usr/lib/dpm/modules/info.so` |
| modules (`raw.so`, `pkg.so`, `repo.so`, `source.so`, ...) | `/usr/lib/dpm/modules/<name>.so` | | modules (`raw.so`, `pkg.so`, `repo.so`, `source.so`, ...) | `/usr/lib/dpm/modules/<name>.so` |
| module records | `/var/lib/dpm/metadata/<name>.meta` |
| alias table | `/var/lib/dpm/metadata/modules.aliases` |
## Development and Testing ## Development and Testing

View File

@@ -4,7 +4,7 @@ A DPM module is one shared object in the module directory. libdpm-core.so loads
## The Module Contract ## The Module Contract
A module includes `<dpm/core.h>`, links `-ldpm-core`, and exports the following symbols as extern "C". All returned strings must be non-NULL, static or module-owned, and valid for the lifetime of the loaded module; callers never free them. A module includes `<dpm/core.h>`, links `-ldpm-core`, and exports the following four symbols as extern "C". All returned strings must be non-NULL, static or module-owned, and valid for the lifetime of the loaded module; callers never free them.
`int dpm_module_execute(dpm_ctx* ctx, const char* command, int argc, char** argv)` `int dpm_module_execute(dpm_ctx* ctx, const char* command, int argc, char** argv)`
The command entry point, and the only entry through which the module performs work. `ctx` is the host context that dispatched the call; the module reaches every service (`dpm_log`, `dpm_config_get`, `dpm_get_resolved_module_path`, ...) through it, and reaches peer modules through it as well. `command` is the subcommand name, equal to argv[0] when argc > 0. NULL or empty `command` must behave as the module's help command. Returns 0 on success, nonzero on failure. Where a nonzero return needs explaining, record the reason with `dpm_set_last_error` immediately before returning, and the caller reads it back with `dpm_get_last_error`. It must be callable immediately after load with no other setup. The command entry point, and the only entry through which the module performs work. `ctx` is the host context that dispatched the call; the module reaches every service (`dpm_log`, `dpm_config_get`, `dpm_get_resolved_module_path`, ...) through it, and reaches peer modules through it as well. `command` is the subcommand name, equal to argv[0] when argc > 0. NULL or empty `command` must behave as the module's help command. Returns 0 on success, nonzero on failure. Where a nonzero return needs explaining, record the reason with `dpm_set_last_error` immediately before returning, and the caller reads it back with `dpm_get_last_error`. It must be callable immediately after load with no other setup.
@@ -15,6 +15,18 @@ The module's own version as an X.Y.Z string. libdpm-core.so reports this value t
`const char* dpm_module_description(void)` `const char* dpm_module_description(void)`
A one-line human-readable description, shown in module listings. A one-line human-readable description, shown in module listings.
`const char* dpm_module_aliases(void)`
A comma-separated list of alternate names your module answers to, or NULL for none:
```
extern "C" const char* dpm_module_aliases(void)
{
return "installer, files";
}
```
NULL is a complete answer. The symbol itself is required, and a module that does not export it is refused at load with the other contract failures.
The `dpm_ctx` type and the service declarations all come from the installed public header: The `dpm_ctx` type and the service declarations all come from the installed public header:
``` ```
@@ -110,7 +122,35 @@ Where your module calls a peer, put a **stub module** in the fixture module path
## Installing ## Installing
Modules install to `lib/dpm/modules` under the install prefix (`/usr/lib/dpm/modules` on a distribution install). libdpm-core.so discovers the module on its next scan; no registration step exists beyond the file being present and valid. Modules install to `lib/dpm/modules` under the install prefix (`/usr/lib/dpm/modules` on a distribution install), and the package's install step then records the module:
```
dpm --install-module mymodule
```
That loads the module once, writes its version and description into `/var/lib/dpm/metadata/mymodule.meta`, and records each alias it declared whose name is free. From then on `dpm --list-modules` reports your module from that record and opens nothing, which is what keeps listing the modules on a system from executing them.
Removal is the counterpart:
```
dpm --uninstall-module mymodule
```
which deletes the record and every alias resolving to the module, leaving the `.so` where it is.
A module present in the module path with no record is reported in listings with a version of `<uninstalled>`. It still loads and runs when a caller requires it by name, with a warning, so a module put in place by hand works before anyone has installed it.
## Aliases
An alias is a second name a module answers to. Your declared aliases are recorded at installation; an operator adds more with:
```
dpm --add-alias mymodule mm
dpm --remove-alias mm
dpm --list-aliases [mymodule]
```
A name is recorded once. Adding a name that is already an alias, or that is an installed module's own name, is refused rather than repointed, so an existing route to a module is never taken over silently. Module names resolve before aliases, so a module's own name always reaches that module.
## A Working Example ## A Working Example

View File

@@ -30,16 +30,19 @@ Two properties make the routing rule real rather than a convention:
The `dpm` binary's subcommand surface is exactly the set of loadable modules. `dpm <module> <command> [args...]` loads that module and hands the command to it; `dpm <module> help` asks the module to describe itself. There is no fixed list of operations baked into the tool, because the tool contains no capability logic — it parses arguments and prints. The `dpm` binary's subcommand surface is exactly the set of loadable modules. `dpm <module> <command> [args...]` loads that module and hands the command to it; `dpm <module> help` asks the module to describe itself. There is no fixed list of operations baked into the tool, because the tool contains no capability logic — it parses arguments and prints.
`dpm --list-modules` shows every module that passes validation, with its version and description. Candidates that fail are excluded from the listing and logged, so what is listed is what can actually be run. `dpm --list-modules` shows every module in the module path with its version and description, read from what installation recorded. A module with no record is shown with a version of `<uninstalled>`. No module is opened to produce the listing, so describing the modules on a system executes none of them.
Four flags redirect the system defaults: Four flags redirect the system defaults:
- `-c, --config-dir PATH` — where configuration is read from - `-c, --config-dir PATH` — where configuration is read from
- `-m, --module-path PATH` — which directory modules are loaded from - `-m, --module-path PATH` — which directory modules are loaded from
- `-M, --metadata-dir PATH` — where module records and the alias table live
- `-r, --root PATH` — the target root that package operations act on - `-r, --root PATH` — the target root that package operations act on
- `-L, --log-level LEVEL` — FATAL, ERROR, WARN, INFO, or DEBUG - `-L, --log-level LEVEL` — FATAL, ERROR, WARN, INFO, or DEBUG
These four exist because of a rule the design imposes on itself: every field a linked program can override must also be a flag, so anything reachable from code is reachable from a shell. That rule holds for any override added in the future. These five exist because of a rule the design imposes on itself: every field a linked program can override must also be a flag, so anything reachable from code is reachable from a shell. That rule holds for any override added in the future.
Five more operations manage what libdpm-core.so has recorded: `--install-module`, `--uninstall-module`, `--add-alias`, `--remove-alias`, and `--list-aliases`.
## Writing a Program Against the Library ## Writing a Program Against the Library
@@ -51,7 +54,7 @@ The target root override is what makes chroot builds, image assembly, and sysroo
The context owns everything it hands out. Every string a caller receives stays valid until `dpm_close`, and callers never free anything. The context owns everything it hands out. Every string a caller receives stays valid until `dpm_close`, and callers never free anything.
**Acquiring a module.** `dpm_require` resolves a module by name, validates it completely, and returns a handle — or returns NULL, with `dpm_get_last_error` carrying the precise reason. Modules load at most once per context, and repeated calls return the same handle. **Acquiring a module.** `dpm_require` resolves a module by name, validates it completely, and returns a handle — or returns NULL, with `dpm_get_last_error` carrying the precise reason. The name is matched against the installed modules first, then the alias table, then the module path itself; reaching the module path means the module is not installed, which is logged and does not stop the load. Modules load at most once per context, and repeated calls return the same handle.
**Reading what the library saw.** `dpm_get_module_info` fills in a module's name, version, and description. Those values are reported, and no conclusion is drawn from them. **Reading what the library saw.** `dpm_get_module_info` fills in a module's name, version, and description. Those values are reported, and no conclusion is drawn from them.
@@ -85,7 +88,9 @@ The `ctx` a module needs is the one handed to it in its own entry point, so it r
**Configuration** lives in `/etc/dpm/conf.d/`. Each `.conf` file in that directory is one namespace named after the file: `core.conf` holds the library's own settings, and a module named `mymodule` reads `mymodule.conf`. Files are sectioned, and a value is addressed by namespace, section, and key. The library's own file carries the log level, whether to write a log file and where, and the default module directory. **Configuration** lives in `/etc/dpm/conf.d/`. Each `.conf` file in that directory is one namespace named after the file: `core.conf` holds the library's own settings, and a module named `mymodule` reads `mymodule.conf`. Files are sectioned, and a value is addressed by namespace, section, and key. The library's own file carries the log level, whether to write a log file and where, and the default module directory.
**Modules** live in `/usr/lib/dpm/modules/`. A module's name is its filename without the `.so` extension, so `info.so` is the module named `info`. Discovery is the presence of a valid file in that directory — there is no registry, and no registration step. **Modules** live in `/usr/lib/dpm/modules/`. A module's name is its filename without the `.so` extension, so `info.so` is the module named `info`.
**Records** live in `/var/lib/dpm/metadata/`. Installing a module writes `<name>.meta` there, holding the version and description the module reported, and records the alternate names it declared in `modules.aliases` beside it. Listings read those files, so describing the modules on a system opens none of them. A module in the module path with no record is reported as uninstalled and still runs when a caller names it.
**Logging** goes to the console always, and to a log file when configuration enables one; the default path is `/var/log/dpm/dpm.log`. **Logging** goes to the console always, and to a log file when configuration enables one; the default path is `/var/log/dpm/dpm.log`.
@@ -99,12 +104,14 @@ The `ctx` a module needs is the one handed to it in its own entry point, so it r
| `libdpm-core.so` | `/usr/lib/libdpm-core.so` | | `libdpm-core.so` | `/usr/lib/libdpm-core.so` |
| public header | `/usr/include/dpm/core.h` | | public header | `/usr/include/dpm/core.h` |
| modules | `/usr/lib/dpm/modules/<name>.so` | | modules | `/usr/lib/dpm/modules/<name>.so` |
| module records | `/var/lib/dpm/metadata/<name>.meta` |
| alias table | `/var/lib/dpm/metadata/modules.aliases` |
## The Module Contract ## The Module Contract
*What a module author implements.* *What a module author implements.*
A module is one `.so` in the module directory exporting three reserved symbols: a command entry point, its own version, and a one-line description. It includes `<dpm/core.h>` for those declarations and links `-ldpm-core`, and that is its entire build dependency. A module is one `.so` in the module directory exporting four reserved symbols: a command entry point, its own version, a one-line description, and the alternate names it answers to. It includes `<dpm/core.h>` for those declarations and links `-ldpm-core`, and that is its entire build dependency.
The entry point receives the context that dispatched the call, the command name, and an argument vector, and returns an int. Everything a module offers the rest of the system is reachable through that one function, addressed by command name — which is what keeps a caller free of any compile-time knowledge of the module it is calling. The entry point receives the context that dispatched the call, the command name, and an argument vector, and returns an int. Everything a module offers the rest of the system is reachable through that one function, addressed by command name — which is what keeps a caller free of any compile-time knowledge of the module it is calling.
@@ -114,7 +121,7 @@ A module is built against the system-installed `libdpm-core.so` and is responsib
libdpm-core.so validates a module completely before offering it to anyone: libdpm-core.so validates a module completely before offering it to anyone:
1. Every reserved contract symbol resolves. 1. Every one of the four reserved contract symbols resolves.
2. The version and description probes return well-formed values. 2. The version and description probes return well-formed values.
A module failing any step is refused with an itemized reason and its library handle closed. Validation is all-or-nothing: if a handle comes back, the contract already passed. Consumers never defend against partially valid modules, because they cannot receive one. A module failing any step is refused with an itemized reason and its library handle closed. Validation is all-or-nothing: if a handle comes back, the contract already passed. Consumers never defend against partially valid modules, because they cannot receive one.

View File

@@ -96,6 +96,18 @@ typedef struct dpm_module dpm_module;
*/ */
typedef struct dpm_cursor dpm_cursor; typedef struct dpm_cursor dpm_cursor;
/**
* @brief A cursor over recorded module aliases
*
* Obtained from dpm_list_module_aliases(), advanced with
* dpm_alias_cursor_next(), and released with dpm_alias_cursor_free().
*
* The cursor holds its own copy of what it reports, so it stays readable
* after the alias table is changed, and releasing it leaves the table
* untouched.
*/
typedef struct dpm_alias_cursor dpm_alias_cursor;
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Log levels */ /* Log levels */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -145,6 +157,9 @@ typedef struct dpm_open_overrides {
/** -1 selects the configured value; otherwise a DPM_LOG_* level. */ /** -1 selects the configured value; otherwise a DPM_LOG_* level. */
int log_level; int log_level;
/** NULL selects the configured value, then the built-in default. */
const char* metadata_dir;
} dpm_open_overrides; } dpm_open_overrides;
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -168,6 +183,20 @@ typedef struct dpm_module_info {
const char* description; const char* description;
} dpm_module_info; } dpm_module_info;
/**
* @brief One recorded alias
*
* Filled by dpm_alias_cursor_next(). The string pointers remain valid
* until the cursor is released.
*/
typedef struct dpm_alias_info {
/** The alternate name. */
const char* alias;
/** The module that name resolves to. */
const char* module;
} dpm_alias_info;
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Context lifecycle */ /* Context lifecycle */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -209,10 +238,15 @@ void dpm_close(dpm_ctx* ctx);
/** /**
* @brief Loads and returns a validated module * @brief Loads and returns a validated module
* *
* Resolves the named module in the module path and runs the full * Resolves the name to a module file and runs the full load-time
* load-time validation sequence if it is not already loaded in this * validation sequence if it is not already loaded in this context.
* context. Modules are loaded at most once per context; repeated * Modules are loaded at most once per context; repeated calls return
* calls return the same handle. * the same handle.
*
* The name is resolved against the installed modules first, then the
* alias table, then the module path directly. Reaching the module path
* means the module is not installed, which is logged and does not stop
* the load.
* *
* Version compatibility is the caller's judgement: read the loaded * Version compatibility is the caller's judgement: read the loaded
* module's version with dpm_get_module_info() and decide whether it is * module's version with dpm_get_module_info() and decide whether it is
@@ -267,18 +301,119 @@ DPM_PUBLIC_ABI_EXPORT
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);
/* ------------------------------------------------------------------ */
/* Installation */
/* ------------------------------------------------------------------ */
/**
* @brief Records what a module reports about itself
*
* Loads the named module once, reads its version, description, and
* declared aliases, and writes them into the metadata directory. From
* then on dpm_list_modules() reports that module from the record and
* opens nothing.
*
* Each declared alias is added when the name is free. A name already
* taken keeps its existing meaning and the installation continues.
*
* @param ctx The libdpm-core.so context
* @param name The module name, its filename minus .so
* @return 0 on success, nonzero on failure with the reason available
* from dpm_get_last_error()
*/
DPM_PUBLIC_ABI_EXPORT
int dpm_install_module(dpm_ctx* ctx, const char* name);
/**
* @brief Removes a module's record and every alias resolving to it
*
* Leaves the module file itself in place. The module is thereafter
* reported as uninstalled by dpm_list_modules(), and dpm_require() still
* loads it by its own name.
*
* @param ctx The libdpm-core.so context
* @param name The module name
* @return 0 on success, nonzero on failure with the reason available
* from dpm_get_last_error()
*/
DPM_PUBLIC_ABI_EXPORT
int dpm_uninstall_module(dpm_ctx* ctx, const char* name);
/* ------------------------------------------------------------------ */
/* Aliases */
/* ------------------------------------------------------------------ */
/**
* @brief Records an alternate name for a module
*
* A name is recorded once. A name already serving as an alias, or
* already belonging to an installed module, is refused, so an existing
* route to a module is never replaced by a later one. Changing where an
* alias points is a removal followed by an addition.
*
* @param ctx The libdpm-core.so context
* @param module The module the name resolves to
* @param alias The alternate name
* @return 0 on success, nonzero on failure with the reason available
* from dpm_get_last_error()
*/
DPM_PUBLIC_ABI_EXPORT
int dpm_add_module_alias(dpm_ctx* ctx, const char* module, const char* alias);
/**
* @brief Removes an alternate name
*
* @param ctx The libdpm-core.so context
* @param alias The alternate name, which identifies the entry on its own
* @return 0 on success, nonzero on failure with the reason available
* from dpm_get_last_error()
*/
DPM_PUBLIC_ABI_EXPORT
int dpm_remove_module_alias(dpm_ctx* ctx, const char* alias);
/**
* @brief Enumerates recorded aliases
*
* @param ctx The libdpm-core.so context
* @param module NULL for every alias on the system, or a module name for
* the aliases resolving to it
* @return A cursor over the matching aliases, or NULL on failure
*/
DPM_PUBLIC_ABI_EXPORT
dpm_alias_cursor* dpm_list_module_aliases(dpm_ctx* ctx, const char* module);
/**
* @brief Advances an alias cursor
*
* @param cur The cursor from dpm_list_module_aliases()
* @param out Receives the next alias and the module it resolves to
* @return 0 while entries remain; nonzero at end
*/
DPM_PUBLIC_ABI_EXPORT
int dpm_alias_cursor_next(dpm_alias_cursor* cur, dpm_alias_info* out);
/**
* @brief Releases an alias cursor
*
* @param cur The cursor to release; NULL is a no-op
*/
DPM_PUBLIC_ABI_EXPORT
void dpm_alias_cursor_free(dpm_alias_cursor* cur);
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Enumeration */ /* Enumeration */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/** /**
* @brief Enumerates the valid modules in the module path * @brief Enumerates the modules in the module path
* *
* Scans the module path and validates each candidate .so; failures * Reports each installed module from its record, and each module
* are logged and excluded from the results. * without one with a version of "&lt;uninstalled&gt;" and an empty
* description. No module is opened, so nothing in the module path is
* executed to produce a listing.
* *
* @param ctx The libdpm-core.so context * @param ctx The libdpm-core.so context
* @return A cursor over all valid modules, or NULL on an unreadable * @return A cursor over the modules present, or NULL on an unreadable
* module path * module path
*/ */
DPM_PUBLIC_ABI_EXPORT DPM_PUBLIC_ABI_EXPORT
@@ -396,10 +531,16 @@ const char* dpm_get_last_error(dpm_ctx* ctx);
* int argc, char** argv); * int argc, char** argv);
* const char* dpm_module_version(void); * const char* dpm_module_version(void);
* const char* dpm_module_description(void); * const char* dpm_module_description(void);
* const char* dpm_module_aliases(void);
* *
* dpm_module_execute is the only entry through which a module performs * dpm_module_execute is the only entry through which a module performs
* work. The other two are what it reports about itself; the library * work. The other three are what it reports about itself; the library
* reads both at load and serves them through dpm_get_module_info. * reads them at load, serves the version and description through
* dpm_get_module_info, and records the aliases at installation.
*
* dpm_module_aliases returns a comma-separated list of alternate names
* the module answers to, or NULL to declare none. NULL is an answer; the
* symbol's absence is a contract violation and the module is refused.
*/ */
#ifdef __cplusplus #ifdef __cplusplus

View File

@@ -0,0 +1,85 @@
/**
* @file aliases.hpp
* @brief The alias table: alternate names a module answers to
*
* @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/>.
*/
#pragma once
#include <dpm/core.h>
#include <string>
#include <vector>
struct dpm_ctx;
/** @brief Enumeration cursor over alias table entries */
struct dpm_alias_cursor {
/** One entry per alias the cursor reports. */
std::vector<dpm_alias_info> infos;
/** Backing strings, so the reported pointers stay valid. */
std::vector<std::pair<std::string, std::string>> storage;
/** Position of the next entry. */
size_t idx = 0;
};
namespace dpm_core {
/**
* @brief Reads the alias table out of a context's metadata directory
*
* The table is modules.aliases, one `alias = module` per line. A
* missing or unreadable file leaves the context holding no aliases,
* which costs those alternate names and costs nothing else.
*
* @param ctx The libdpm-core.so context
*/
void alias_load_table(dpm_ctx* ctx);
/**
* @brief Writes the context's alias table back to disk
*
* @param ctx The libdpm-core.so context
* @param reason Receives the failure reason when the write fails
* @return true on success
*/
bool alias_write_table(dpm_ctx* ctx, std::string& reason);
/**
* @brief Resolves an alias to the module it names
*
* @param ctx The libdpm-core.so context
* @param alias The alias to resolve
* @return The module name, or an empty string when the alias is unknown
*/
std::string alias_resolve(dpm_ctx* ctx, const std::string& alias);
/**
* @brief Splits a module's comma-separated alias list
*
* The form dpm_module_aliases() returns. Empty entries are dropped
* and each surviving name is trimmed, so trailing separators and
* spacing cost nothing.
*
* @param declared The value the module reported; NULL declares none
* @return One entry per name the module answers to
*/
std::vector<std::string> alias_split_declared(const char* declared);
} // namespace dpm_core

View File

@@ -28,6 +28,7 @@
#include <memory> #include <memory>
#include <string> #include <string>
#include "internal/metadata.hpp"
#include "internal/modules.hpp" #include "internal/modules.hpp"
/** @brief A libdpm-core.so context: configuration, logging, module registry */ /** @brief A libdpm-core.so context: configuration, logging, module registry */
@@ -38,6 +39,9 @@ struct dpm_ctx {
/** Directory modules are loaded from. */ /** Directory modules are loaded from. */
std::string module_path; std::string module_path;
/** Directory the .meta records and modules.aliases were read from. */
std::string metadata_dir;
/** Target root for package operations. */ /** Target root for package operations. */
std::string root; std::string root;
@@ -58,6 +62,15 @@ struct dpm_ctx {
/** Validated modules keyed by name; each is loaded at most once. */ /** Validated modules keyed by name; each is loaded at most once. */
std::map<std::string, std::unique_ptr<dpm_module>> modules; std::map<std::string, std::unique_ptr<dpm_module>> modules;
/** Records of installed modules, keyed by module name. */
std::map<std::string, dpm_module_meta_records> meta_records;
/** Placeholder records a listing reports for modules carrying none. */
std::map<std::string, dpm_module_meta_records> uninstalled;
/** Alternate module names, keyed by alias. */
std::map<std::string, std::string> aliases;
/** Reason for the most recent failure. */ /** Reason for the most recent failure. */
std::string last_error; std::string last_error;
}; };

View File

@@ -0,0 +1,128 @@
/**
* @file metadata.hpp
* @brief Module records: what libdpm-core.so knows without loading a module
*
* @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/>.
*/
#pragma once
#include <map>
#include <string>
struct dpm_ctx;
/**
* @brief The records one module's .meta file carries
*
* Written when a module is installed, from what the module itself
* reported at that moment. A listing reports these values, so a listing
* needs to open no module.
*/
struct dpm_module_meta_records {
/** Basename of the module file these records describe. */
std::string module_file;
/** Version the module reported when it was installed. */
std::string version;
/** Description the module reported when it was installed. */
std::string description;
};
namespace dpm_core {
/**
* @brief Reads a flat key/value file from the metadata directory
*
* The format every file in that directory uses: one `key = value`
* per line, '#' and ';' opening a comment, and every key and value
* trimmed. A line carrying no '=' is skipped, so a malformed line
* costs that line and no more.
*
* @param path The file to read
* @param out Receives the pairs the file carried
* @return true when the file was opened; false when it was not
*/
bool md_read_key_value_file(const std::string& path,
std::map<std::string, std::string>& out);
/**
* @brief Writes a flat key/value file into the metadata directory
*
* Creates the directory when it is absent, and replaces the file's
* whole contents.
*
* @param path The file to write
* @param values The pairs to write, one per line
* @param reason Receives the failure reason when the write fails
* @return true on success
*/
bool md_write_key_value_file(
const std::string& path,
const std::map<std::string, std::string>& values,
std::string& reason);
/**
* @brief Reads every module record in a context's metadata directory
*
* Each .meta file becomes one set of records named after the file
* without its extension. A missing or unreadable directory leaves
* the context holding no records, which costs a listing its detail
* and costs nothing else.
*
* @param ctx The libdpm-core.so context
*/
void md_load_records_dir(dpm_ctx* ctx);
/**
* @brief Finds the records for a module name
*
* @param ctx The libdpm-core.so context
* @param module_name The module name to look up
* @return The records, or nullptr when no module of that name is recorded
*/
const dpm_module_meta_records* md_find_module_record(
dpm_ctx* ctx, const std::string& module_name);
/**
* @brief Writes one module's records into the metadata directory
*
* Replaces the records of the same module name, and updates the
* context's own copy so the change is visible without reopening.
*
* @param ctx The libdpm-core.so context
* @param module_name The module name the records belong to
* @param records The records to write
* @param reason Receives the failure reason when the write fails
* @return true on success
*/
bool md_write_module_record(dpm_ctx* ctx, const std::string& module_name,
const dpm_module_meta_records& records,
std::string& reason);
/**
* @brief Removes one module's records from the metadata directory
*
* @param ctx The libdpm-core.so context
* @param module_name The module name whose records are removed
* @param reason Receives the failure reason when the removal fails
* @return true on success
*/
bool md_remove_module_record(dpm_ctx* ctx, const std::string& module_name,
std::string& reason);
} // namespace dpm_core

View File

@@ -42,6 +42,9 @@ struct dpm_module {
/** dpm_module_description() as read at load. */ /** dpm_module_description() as read at load. */
std::string description; std::string description;
/** dpm_module_aliases() as read at load; empty when it returned NULL. */
std::string aliases;
/** Resolved dpm_module_execute; the only path into module code. */ /** Resolved dpm_module_execute; the only path into module code. */
int (*execute)(dpm_ctx*, const char*, int, char**) = nullptr; int (*execute)(dpm_ctx*, const char*, int, char**) = nullptr;
}; };

View File

@@ -32,4 +32,12 @@ namespace dpm_core {
* @return The path, ending in a slash unless it is empty * @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);
/**
* @brief Strips leading and trailing whitespace
*
* @param s The string to trim
* @return The trimmed string; empty when the input is entirely whitespace
*/
std::string trim(const std::string& s);
} // namespace dpm_core } // namespace dpm_core

View File

@@ -47,6 +47,16 @@ extern "C" const char* dpm_module_description(void) {
return "Reports and tests libdpm-core.so functionality."; return "Reports and tests libdpm-core.so functionality.";
} }
/**
* @brief Returns the alternate names the module answers to
*
* NULL declares none, which is what this module wants: it is addressed
* as "info" and nothing else.
*/
extern "C" const char* dpm_module_aliases(void) {
return nullptr;
}
/** /**
* @brief Command entry point * @brief Command entry point
* *

View File

@@ -50,11 +50,19 @@ namespace {
"Options:\n" "Options:\n"
" -c, --config-dir PATH Configuration directory (default /etc/dpm/conf.d/)\n" " -c, --config-dir PATH Configuration directory (default /etc/dpm/conf.d/)\n"
" -m, --module-path PATH Module directory (overrides configuration)\n" " -m, --module-path PATH Module directory (overrides configuration)\n"
" -M, --metadata-dir PATH Module record directory (overrides configuration)\n"
" -r, --root PATH Target root for package operations (default /)\n" " -r, --root PATH Target root for package operations (default /)\n"
" -L, --log-level LEVEL FATAL, ERROR, WARN, INFO, or DEBUG\n" " -L, --log-level LEVEL FATAL, ERROR, WARN, INFO, or DEBUG\n"
" -l, --list-modules List available modules\n" " -l, --list-modules List available modules\n"
" -h, --help Show this help message\n" " -h, --help Show this help message\n"
"\n" "\n"
"Module records:\n"
" --install-module NAME Record what a module reports about itself\n"
" --uninstall-module NAME Remove a module's record and its aliases\n"
" --add-alias MODULE ALIAS Record an alternate name for a module\n"
" --remove-alias ALIAS Remove an alternate name\n"
" --list-aliases [MODULE] List recorded aliases\n"
"\n"
"For module-specific help, use: dpm <module> help\n"); "For module-specific help, use: dpm <module> help\n");
} }
@@ -125,7 +133,7 @@ namespace {
dpm_cursor_free(cur); dpm_cursor_free(cur);
if (infos.empty()) { if (infos.empty()) {
std::printf("No valid modules found in %s\n", std::printf("No modules found in %s\n",
dpm_get_resolved_module_path(ctx)); dpm_get_resolved_module_path(ctx));
return 0; return 0;
} }
@@ -152,17 +160,70 @@ namespace {
return 0; return 0;
} }
/**
* @brief Prints the table of recorded aliases
*
* @param ctx The libdpm-core.so context
* @param module NULL for every alias, or a module name to filter by
* @return 0 on success, 1 on failure
*/
int list_aliases(dpm_ctx* ctx, const char* module) {
dpm_alias_cursor* cur = dpm_list_module_aliases(ctx, module);
if (!cur) {
const char* err = dpm_get_last_error(ctx);
std::fprintf(stderr, "dpm: %s\n", err ? err : "alias listing failed");
return 1;
}
std::vector<dpm_alias_info> infos;
dpm_alias_info info;
while (dpm_alias_cursor_next(cur, &info) == 0) {
infos.push_back(info);
}
if (infos.empty()) {
std::printf("No aliases recorded\n");
dpm_alias_cursor_free(cur);
return 0;
}
size_t alias_w = std::strlen("ALIAS");
for (const auto& i : infos) {
alias_w = std::max(alias_w, std::strlen(i.alias));
}
std::printf("%-*s %s\n", static_cast<int>(alias_w), "ALIAS",
"MODULE");
for (const auto& i : infos) {
std::printf("%-*s %s\n", static_cast<int>(alias_w), i.alias,
i.module);
}
dpm_alias_cursor_free(cur);
return 0;
}
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
dpm_open_overrides overrides = {nullptr, nullptr, nullptr, -1}; dpm_open_overrides overrides = {nullptr, nullptr, nullptr, -1, nullptr};
bool list = false; bool list = false;
/* Holders for --opt=value forms. */ /* Holders for --opt=value forms. */
std::string config_dir; std::string config_dir;
std::string module_path; std::string module_path;
std::string metadata_dir;
std::string root; std::string root;
/* Record operations, each holding what its flag was given. */
const char* install_module = nullptr;
const char* uninstall_module = nullptr;
const char* alias_module = nullptr;
const char* alias_name = nullptr;
const char* remove_alias = nullptr;
const char* list_alias_of = nullptr;
bool aliases_requested = false;
int i = 1; int i = 1;
for (; i < argc; i++) { for (; i < argc; i++) {
const char* arg = argv[i]; const char* arg = argv[i];
@@ -195,6 +256,11 @@ int main(int argc, char** argv) {
if (!v) { return 1; } if (!v) { return 1; }
module_path = v; module_path = v;
overrides.module_path = module_path.c_str(); overrides.module_path = module_path.c_str();
} else if (option_matches(arg, "-M", "--metadata-dir", &inline_value)) {
const char* v = take_value();
if (!v) { return 1; }
metadata_dir = v;
overrides.metadata_dir = metadata_dir.c_str();
} else if (option_matches(arg, "-r", "--root", &inline_value)) { } else if (option_matches(arg, "-r", "--root", &inline_value)) {
const char* v = take_value(); const char* v = take_value();
if (!v) { return 1; } if (!v) { return 1; }
@@ -214,6 +280,34 @@ int main(int argc, char** argv) {
} else if (std::strcmp(arg, "-l") == 0 || } else if (std::strcmp(arg, "-l") == 0 ||
std::strcmp(arg, "--list-modules") == 0) { std::strcmp(arg, "--list-modules") == 0) {
list = true; list = true;
} else if (option_matches(arg, "--install-module", "--install-module",
&inline_value)) {
install_module = take_value();
if (!install_module) { return 1; }
} else if (option_matches(arg, "--uninstall-module",
"--uninstall-module", &inline_value)) {
uninstall_module = take_value();
if (!uninstall_module) { return 1; }
} else if (std::strcmp(arg, "--add-alias") == 0) {
if (i + 2 >= argc) {
std::fprintf(stderr,
"dpm: --add-alias requires MODULE and ALIAS\n");
return 1;
}
alias_module = argv[++i];
alias_name = argv[++i];
} else if (option_matches(arg, "--remove-alias", "--remove-alias",
&inline_value)) {
remove_alias = take_value();
if (!remove_alias) { return 1; }
} else if (std::strcmp(arg, "--list-aliases") == 0) {
aliases_requested = true;
// The module argument is optional, so a following word is
// taken only when it is not another option.
if (i + 1 < argc && argv[i + 1][0] != '-') {
list_alias_of = argv[++i];
}
} else if (std::strcmp(arg, "-h") == 0 || } else if (std::strcmp(arg, "-h") == 0 ||
std::strcmp(arg, "--help") == 0) { std::strcmp(arg, "--help") == 0) {
show_help(); show_help();
@@ -239,6 +333,56 @@ int main(int argc, char** argv) {
return rc; return rc;
} }
if (install_module) {
int rc = dpm_install_module(ctx, install_module);
if (rc != 0) {
const char* err = dpm_get_last_error(ctx);
std::fprintf(stderr, "dpm: %s\n",
err ? err : "module could not be installed");
}
dpm_close(ctx);
return rc;
}
if (uninstall_module) {
int rc = dpm_uninstall_module(ctx, uninstall_module);
if (rc != 0) {
const char* err = dpm_get_last_error(ctx);
std::fprintf(stderr, "dpm: %s\n",
err ? err : "module could not be uninstalled");
}
dpm_close(ctx);
return rc;
}
if (alias_module) {
int rc = dpm_add_module_alias(ctx, alias_module, alias_name);
if (rc != 0) {
const char* err = dpm_get_last_error(ctx);
std::fprintf(stderr, "dpm: %s\n",
err ? err : "alias could not be recorded");
}
dpm_close(ctx);
return rc;
}
if (remove_alias) {
int rc = dpm_remove_module_alias(ctx, remove_alias);
if (rc != 0) {
const char* err = dpm_get_last_error(ctx);
std::fprintf(stderr, "dpm: %s\n",
err ? err : "alias could not be removed");
}
dpm_close(ctx);
return rc;
}
if (aliases_requested) {
int rc = list_aliases(ctx, list_alias_of);
dpm_close(ctx);
return rc;
}
if (i >= argc) { if (i >= argc) {
show_help(); show_help();
dpm_close(ctx); dpm_close(ctx);

267
src/core/aliases.cpp Normal file
View File

@@ -0,0 +1,267 @@
/**
* @file aliases.cpp
* @brief The alias table: alternate names a module answers to
*
* An alias is a second name for a module. A module declares its own
* through dpm_module_aliases(), and an administrator adds more through
* dpm_add_module_alias(); once written, the two are the same thing.
*
* The table lives in the metadata directory as modules.aliases, flat
* key/value text where the key is the alias and the value is the module
* it resolves to:
*
* @code
* installer = raw
* files = raw
* p = pkg
* @endcode
*
* A name is recorded once. Adding a name that is already an alias, or
* that is already an installed module's own name, is refused with the
* reason on the context, so an existing route to a module is never
* replaced by a later one. Repointing an alias is a removal followed by
* an addition, both of which the operator performs deliberately.
*
* @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/aliases.hpp"
#include "internal/context.hpp"
#include "internal/metadata.hpp"
#include "internal/sanitizers.hpp"
#include <new>
/**
* @brief Helpers private to this translation unit
*
* Where the alias table lives. An unnamed namespace gives it internal
* linkage, so it is unreachable from the library's other files and
* cannot collide with a same-named helper in one of them.
*/
namespace {
/**
* @brief Builds the path of the alias table
*
* @param ctx The context whose metadata directory is used
* @return The full path of modules.aliases
*/
std::string alias_table_path(dpm_ctx* ctx) {
return ctx->metadata_dir + "modules.aliases";
}
} // namespace
namespace dpm_core {
void alias_load_table(dpm_ctx* ctx) {
std::map<std::string, std::string> values;
if (!md_read_key_value_file(alias_table_path(ctx), values)) {
return;
}
for (const auto& [alias, module_name] : values) {
// An entry naming no module resolves nowhere, so it is dropped.
if (module_name.empty()) {
continue;
}
ctx->aliases[alias] = module_name;
}
}
bool alias_write_table(dpm_ctx* ctx, std::string& reason) {
return md_write_key_value_file(alias_table_path(ctx), ctx->aliases,
reason);
}
std::string alias_resolve(dpm_ctx* ctx, const std::string& alias) {
if (!ctx) {
return "";
}
auto it = ctx->aliases.find(alias);
if (it == ctx->aliases.end()) {
return "";
}
return it->second;
}
std::vector<std::string> alias_split_declared(const char* declared) {
std::vector<std::string> names;
if (!declared) {
return names;
}
std::string list = declared;
size_t start = 0;
while (start <= list.length()) {
size_t comma = list.find(',', start);
size_t end = comma == std::string::npos ? list.length() : comma;
std::string name = trim(list.substr(start, end - start));
if (!name.empty()) {
names.push_back(name);
}
if (comma == std::string::npos) {
break;
}
start = comma + 1;
}
return names;
}
} // namespace dpm_core
extern "C" {
/**
* @brief Records an alternate name for a module
*
* A name already taken is refused, with the reason naming what holds
* it. Two names collide: one already recorded as an alias, and one
* that is an installed module's own name, which resolution reaches
* before it reaches the table. Changing where an existing alias
* points is a removal followed by an addition.
*/
int dpm_add_module_alias(dpm_ctx* ctx, const char* module,
const char* alias) {
if (!ctx || !module || !*module || !alias || !*alias) {
return 1;
}
auto existing = ctx->aliases.find(alias);
if (existing != ctx->aliases.end()) {
dpm_set_last_error(ctx, ("alias '" + std::string(alias) +
"' already resolves to module '" +
existing->second + "'").c_str());
return 1;
}
if (dpm_core::md_find_module_record(ctx, alias)) {
dpm_set_last_error(ctx, ("alias '" + std::string(alias) +
"' is the name of an installed module")
.c_str());
return 1;
}
ctx->aliases[alias] = module;
std::string reason;
if (!dpm_core::alias_write_table(ctx, reason)) {
// The table on disk is what resolution will read next time, so
// a failed write leaves the context matching it.
ctx->aliases.erase(alias);
dpm_set_last_error(ctx, ("alias '" + std::string(alias) +
"': " + reason).c_str());
return 1;
}
return 0;
}
/**
* @brief Removes an alternate name
*
* An alias names one module, so the alias alone identifies the entry
* to remove.
*/
int dpm_remove_module_alias(dpm_ctx* ctx, const char* alias) {
if (!ctx || !alias || !*alias) {
return 1;
}
auto it = ctx->aliases.find(alias);
if (it == ctx->aliases.end()) {
dpm_set_last_error(ctx, ("alias '" + std::string(alias) +
"' is not recorded").c_str());
return 1;
}
std::string previous = it->second;
ctx->aliases.erase(it);
std::string reason;
if (!dpm_core::alias_write_table(ctx, reason)) {
ctx->aliases[alias] = previous;
dpm_set_last_error(ctx, ("alias '" + std::string(alias) +
"': " + reason).c_str());
return 1;
}
return 0;
}
/**
* @brief Enumerates recorded aliases
*
* The cursor carries its own copies of what it reports, so it stays
* readable after the table is rewritten.
*/
dpm_alias_cursor* dpm_list_module_aliases(dpm_ctx* ctx,
const char* module) {
if (!ctx) {
return nullptr;
}
auto* cur = new (std::nothrow) dpm_alias_cursor;
if (!cur) {
return nullptr;
}
for (const auto& [alias, module_name] : ctx->aliases) {
if (module && *module && module_name != module) {
continue;
}
cur->storage.emplace_back(alias, module_name);
}
// storage is filled before any pointer into it is taken, so
// growing it cannot move a string out from under an entry.
for (const auto& [alias, module_name] : cur->storage) {
dpm_alias_info info;
info.alias = alias.c_str();
info.module = module_name.c_str();
cur->infos.push_back(info);
}
return cur;
}
/**
* @brief Advances an alias 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_alias_cursor_next(dpm_alias_cursor* cur, dpm_alias_info* out) {
if (!cur || !out || cur->idx >= cur->infos.size()) {
return 1;
}
*out = cur->infos[cur->idx];
cur->idx++;
return 0;
}
/**
* @brief Releases an alias cursor
*/
void dpm_alias_cursor_free(dpm_alias_cursor* cur) {
delete cur;
}
} /* extern "C" */

View File

@@ -50,6 +50,7 @@
#include "internal/conf.hpp" #include "internal/conf.hpp"
#include "internal/context.hpp" #include "internal/context.hpp"
#include "internal/sanitizers.hpp"
#include <cctype> #include <cctype>
#include <filesystem> #include <filesystem>
@@ -61,34 +62,11 @@ namespace fs = std::filesystem;
/** /**
* @brief Helpers private to this translation unit * @brief Helpers private to this translation unit
* *
* The line-level work behind parsing. An unnamed namespace gives them * The file-level work behind parsing. An unnamed namespace gives it
* internal linkage, so they are unreachable from the library's other * internal linkage, so it is unreachable from the library's other files
* files and cannot collide with a same-named helper in one of them. * and cannot collide with a same-named helper in one of them.
*/ */
namespace { namespace {
/**
* @brief Strips leading and trailing whitespace
*
* Applied to every line, section name, key, and value, so that
* surrounding spaces and the line's terminator never reach the 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 Reads one configuration file into the store * @brief Reads one configuration file into the store
* *
@@ -112,7 +90,7 @@ namespace {
std::string section = "main"; std::string section = "main";
while (std::getline(in, line)) { while (std::getline(in, line)) {
line = trim(line); line = dpm_core::trim(line);
// Blank line or comment. // Blank line or comment.
if (line.empty() || line[0] == '#' || line[0] == ';') { if (line.empty() || line[0] == '#' || line[0] == ';') {
@@ -121,7 +99,8 @@ namespace {
// Section header: everything after it files under this name. // 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 =
dpm_core::trim(line.substr(1, line.length() - 2));
section = name.empty() ? "main" : name; section = name.empty() ? "main" : name;
continue; continue;
} }
@@ -132,8 +111,8 @@ namespace {
continue; continue;
} }
std::string key = trim(line.substr(0, eq)); std::string key = dpm_core::trim(line.substr(0, eq));
std::string value = trim(line.substr(eq + 1)); std::string value = dpm_core::trim(line.substr(eq + 1));
// A separator with nothing before it names no key. // A separator with nothing before it names no key.
if (key.empty()) { if (key.empty()) {

View File

@@ -22,7 +22,9 @@
*/ */
#include "internal/context.hpp" #include "internal/context.hpp"
#include "internal/aliases.hpp"
#include "internal/conf.hpp" #include "internal/conf.hpp"
#include "internal/metadata.hpp"
#include "internal/sanitizers.hpp" #include "internal/sanitizers.hpp"
#include <filesystem> #include <filesystem>
@@ -47,6 +49,9 @@ namespace {
/** Log file used when configuration enables logging without naming a path. */ /** 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";
/** Metadata directory used when neither an override nor configuration names one. */
const char* DEFAULT_METADATA_DIR = "/var/lib/dpm/metadata/";
} // namespace } // namespace
extern "C" { extern "C" {
@@ -120,6 +125,22 @@ extern "C" {
ctx->module_path = dpm_core::with_trailing_slash(overrides->module_path); ctx->module_path = dpm_core::with_trailing_slash(overrides->module_path);
} }
// Metadata directory: default, then configuration, then the override.
ctx->metadata_dir = DEFAULT_METADATA_DIR;
if (const char* v = dpm_config_get(ctx, "core", "modules", "metadata")) {
ctx->metadata_dir = dpm_core::with_trailing_slash(v);
}
if (overrides && overrides->metadata_dir && *overrides->metadata_dir) {
ctx->metadata_dir =
dpm_core::with_trailing_slash(overrides->metadata_dir);
}
// The records and the alias table describe what is installed, and
// both are read once so that listing and name resolution never
// open a module.
dpm_core::md_load_records_dir(ctx);
dpm_core::alias_load_table(ctx);
// Target root: override, then default. // Target root: override, then default.
ctx->root = "/"; ctx->root = "/";
if (overrides && overrides->root && *overrides->root) { if (overrides && overrides->root && *overrides->root) {

225
src/core/metadata.cpp Normal file
View File

@@ -0,0 +1,225 @@
/**
* @file metadata.cpp
* @brief The metadata directory: module records read, written, and removed
*
* Holds what libdpm-core.so knows about a module without opening it. A
* module is installed by loading it once and writing down what it
* reported; from then on a listing reads those records and executes
* nothing.
*
* Every file in the metadata directory is flat key/value text:
*
* @code
* version = 1.4.0
* description = File-based installer
* @endcode
*
* One .meta file per installed module, named for the module file's
* basename, so raw.so is recorded in raw.meta. A module in the module
* path with no .meta file is uninstalled: it is reported as such in a
* listing, and it still loads and runs when a caller requires it by
* name.
*
* @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/metadata.hpp"
#include "internal/context.hpp"
#include "internal/sanitizers.hpp"
#include <filesystem>
#include <fstream>
namespace fs = std::filesystem;
/**
* @brief Helpers private to this translation unit
*
* Where a module's record lives. An unnamed namespace gives it internal
* linkage, so it is unreachable from the library's other files and
* cannot collide with a same-named helper in one of them.
*/
namespace {
/**
* @brief Builds the path of a module's record
*
* @param ctx The context whose metadata directory is used
* @param module_name The module name
* @return The full path of that module's .meta file
*/
std::string md_record_path(dpm_ctx* ctx, const std::string& module_name) {
return ctx->metadata_dir + module_name + ".meta";
}
} // namespace
namespace dpm_core {
bool md_read_key_value_file(const std::string& path,
std::map<std::string, std::string>& out) {
std::ifstream in(path);
if (!in.is_open()) {
return false;
}
std::string line;
while (std::getline(in, line)) {
line = trim(line);
// Blank line or comment.
if (line.empty() || line[0] == '#' || line[0] == ';') {
continue;
}
// Anything without a separator is not a key/value pair.
size_t eq = line.find('=');
if (eq == std::string::npos) {
continue;
}
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;
}
out[key] = value;
}
return true;
}
bool md_write_key_value_file(
const std::string& path,
const std::map<std::string, std::string>& values,
std::string& reason) {
fs::path file(path);
std::error_code ec;
fs::create_directories(file.parent_path(), ec);
if (ec) {
reason = "cannot create " + file.parent_path().string() + ": " +
ec.message();
return false;
}
std::ofstream out(path, std::ios::trunc);
if (!out.is_open()) {
reason = "cannot open " + path + " for writing";
return false;
}
for (const auto& [key, value] : values) {
out << key << " = " << value << "\n";
}
out.close();
if (out.fail()) {
reason = "writing " + path + " did not complete";
return false;
}
return true;
}
void md_load_records_dir(dpm_ctx* ctx) {
std::error_code ec;
if (!fs::is_directory(ctx->metadata_dir, ec)) {
return;
}
for (const auto& entry : fs::directory_iterator(ctx->metadata_dir, ec)) {
// Stop on a directory read error rather than iterating further.
if (ec) {
break;
}
if (!entry.is_regular_file()) {
continue;
}
if (entry.path().extension() != ".meta") {
continue;
}
std::map<std::string, std::string> values;
if (!md_read_key_value_file(entry.path().string(), values)) {
continue;
}
// The module name is the filename without its extension.
std::string module_name = entry.path().stem().string();
dpm_module_meta_records records;
records.module_file = module_name;
records.version = values["version"];
records.description = values["description"];
ctx->meta_records[module_name] = records;
}
}
const dpm_module_meta_records* md_find_module_record(
dpm_ctx* ctx, const std::string& module_name) {
if (!ctx) {
return nullptr;
}
auto it = ctx->meta_records.find(module_name);
if (it == ctx->meta_records.end()) {
return nullptr;
}
return &it->second;
}
bool md_write_module_record(dpm_ctx* ctx, const std::string& module_name,
const dpm_module_meta_records& records,
std::string& reason) {
std::map<std::string, std::string> values;
values["version"] = records.version;
values["description"] = records.description;
if (!md_write_key_value_file(md_record_path(ctx, module_name), values,
reason)) {
return false;
}
// The context carries the change, so a listing taken through this
// context reports it without reopening.
ctx->meta_records[module_name] = records;
return true;
}
bool md_remove_module_record(dpm_ctx* ctx, const std::string& module_name,
std::string& reason) {
std::string path = md_record_path(ctx, module_name);
std::error_code ec;
bool removed = fs::remove(path, ec);
if (ec) {
reason = "cannot remove " + path + ": " + ec.message();
return false;
}
if (!removed) {
reason = "no record at " + path;
return false;
}
ctx->meta_records.erase(module_name);
return true;
}
} // namespace dpm_core

View File

@@ -29,6 +29,8 @@
*/ */
#include "internal/context.hpp" #include "internal/context.hpp"
#include "internal/aliases.hpp"
#include "internal/metadata.hpp"
#include "internal/version.hpp" #include "internal/version.hpp"
#include <algorithm> #include <algorithm>
@@ -61,6 +63,9 @@ void dpm_internal_unload(void* handle) {
* collide with a same-named helper in one of them. * collide with a same-named helper in one of them.
*/ */
namespace { namespace {
/** Version a listing reports for a module that carries no record. */
const char* UNINSTALLED_VERSION = "<uninstalled>";
/** Signature of a module's command entry point. */ /** 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**);
@@ -134,6 +139,7 @@ namespace dpm_core {
"dpm_module_execute", "dpm_module_execute",
"dpm_module_version", "dpm_module_version",
"dpm_module_description", "dpm_module_description",
"dpm_module_aliases",
}; };
// Collect every missing symbol rather than stopping at the first, // Collect every missing symbol rather than stopping at the first,
@@ -156,6 +162,7 @@ namespace dpm_core {
auto exec_f = reinterpret_cast<execute_fn>(resolve(handle, "dpm_module_execute")); 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 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"));
auto alias_f = reinterpret_cast<string_fn>(resolve(handle, "dpm_module_aliases"));
// Step 2: the probes are called immediately, so a module that // Step 2: the probes are called immediately, so a module that
// resolves its symbols but answers badly is caught here rather // resolves its symbols but answers badly is caught here rather
@@ -175,6 +182,10 @@ namespace dpm_core {
return nullptr; return nullptr;
} }
// A module declaring no alternate names answers NULL, which is a
// complete answer and leaves the list empty.
const char* declared_aliases = alias_f();
// Validation passed, so the values read above are recorded and // Validation passed, so the values read above are recorded and
// the handle becomes the module's for the life of the context. // 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>();
@@ -182,6 +193,7 @@ namespace dpm_core {
mod->handle = handle; mod->handle = handle;
mod->version = version; mod->version = version;
mod->description = description; mod->description = description;
mod->aliases = declared_aliases ? declared_aliases : "";
mod->execute = exec_f; mod->execute = exec_f;
return mod; return mod;
} }
@@ -204,16 +216,33 @@ extern "C" {
return nullptr; return nullptr;
} }
// An installed module answers to its own name first, then to any
// alias recorded for it. A name matching neither is taken as a
// module file in the module path, which is what makes a module
// that was never installed still reachable.
std::string module_file = name;
if (!dpm_core::md_find_module_record(ctx, name)) {
std::string aliased = dpm_core::alias_resolve(ctx, name);
if (!aliased.empty()) {
module_file = aliased;
} else {
dpm_log(ctx, DPM_LOG_WARN,
("module '" + std::string(name) +
"' is not installed; loading it from the module "
"path directly").c_str());
}
}
// Already loaded in this context: hand back the same handle. // Already loaded in this context: hand back the same handle.
auto it = ctx->modules.find(name); auto it = ctx->modules.find(module_file);
if (it != ctx->modules.end()) { if (it != ctx->modules.end()) {
return it->second.get(); return it->second.get();
} }
std::string reason; std::string reason;
auto loaded = dpm_core::validate_and_load(ctx, name, reason); auto loaded = dpm_core::validate_and_load(ctx, module_file, reason);
if (!loaded) { if (!loaded) {
dpm_set_last_error(ctx, (std::string("module '") + name + dpm_set_last_error(ctx, ("module '" + module_file +
"': " + reason).c_str()); "': " + reason).c_str());
return nullptr; return nullptr;
} }
@@ -221,7 +250,7 @@ extern "C" {
// The registry takes ownership; the returned pointer stays valid // The registry takes ownership; the returned pointer stays valid
// until the context closes. // until the context closes.
dpm_module* mod = loaded.get(); dpm_module* mod = loaded.get();
ctx->modules[name] = std::move(loaded); ctx->modules[module_file] = std::move(loaded);
return mod; return mod;
} }
@@ -258,15 +287,12 @@ extern "C" {
} }
/** /**
* @brief Enumerates the valid modules in the module path * @brief Enumerates the modules in the module path
* *
* Scans the directory and validates every candidate, so a module * Reads what installation recorded and opens nothing, so listing the
* reaching the cursor is one that can actually be run. A candidate * modules on a system executes none of them. An installed module is
* that fails validation is logged with its reason and left out, * reported from its record; a module with no record is reported as
* which keeps a broken module from making the whole listing fail. * uninstalled, which is what a caller sees until someone installs it.
*
* 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) {
@@ -303,37 +329,134 @@ extern "C" {
} }
for (const std::string& name : names) { for (const std::string& name : names) {
dpm_module* mod = nullptr; const dpm_module_meta_records* records =
dpm_core::md_find_module_record(ctx, name);
auto it = ctx->modules.find(name); if (records) {
if (it != ctx->modules.end()) { dpm_module_info info;
mod = it->second.get(); info.name = records->module_file.c_str();
} else { info.version = records->version.c_str();
std::string reason; info.description = records->description.c_str();
auto loaded = dpm_core::validate_and_load(ctx, name, reason); cur->infos.push_back(info);
// 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: " +
reason).c_str());
continue; continue;
} }
mod = loaded.get();
ctx->modules[name] = std::move(loaded); dpm_log(ctx, DPM_LOG_WARN,
} ("module '" + name + "' is present but not installed")
.c_str());
// The strings an entry reports outlive the loop, so the name
// is kept where the context can hold it.
dpm_module_meta_records placeholder;
placeholder.module_file = name;
placeholder.version = UNINSTALLED_VERSION;
ctx->uninstalled[name] = placeholder;
dpm_module_info info; dpm_module_info info;
info.name = mod->name.c_str(); info.name = ctx->uninstalled[name].module_file.c_str();
info.version = mod->version.c_str(); info.version = ctx->uninstalled[name].version.c_str();
info.description = mod->description.c_str(); info.description = ctx->uninstalled[name].description.c_str();
cur->infos.push_back(info); cur->infos.push_back(info);
} }
return cur; return cur;
} }
/**
* @brief Records what a module reports about itself
*
* The one operation that opens a module for bookkeeping rather than
* to run it. What the module answers becomes its record, so every
* later listing reads the record and opens nothing.
*
* A declared alias whose name is free is recorded. A name already
* taken keeps what it means and the installation carries on, since a
* module is usable whether or not every alternate name it wanted was
* available.
*/
int dpm_install_module(dpm_ctx* ctx, const char* name) {
if (!ctx || !name || !*name) {
return 1;
}
std::string reason;
auto loaded = dpm_core::validate_and_load(ctx, name, reason);
if (!loaded) {
dpm_set_last_error(ctx, ("module '" + std::string(name) +
"': " + reason).c_str());
return 1;
}
dpm_module_meta_records records;
records.module_file = name;
records.version = loaded->version;
records.description = loaded->description;
std::string declared = loaded->aliases;
// The module was opened to be read, so it is closed again here
// rather than joining the registry of modules that will be run.
dpm_internal_unload(loaded->handle);
loaded->handle = nullptr;
if (!dpm_core::md_write_module_record(ctx, name, records, reason)) {
dpm_set_last_error(ctx, ("module '" + std::string(name) +
"': " + reason).c_str());
return 1;
}
for (const std::string& alias : dpm_core::alias_split_declared(
declared.empty() ? nullptr : declared.c_str())) {
if (dpm_add_module_alias(ctx, name, alias.c_str()) != 0) {
const char* err = dpm_get_last_error(ctx);
dpm_log(ctx, DPM_LOG_WARN,
("module '" + std::string(name) + "': " +
(err ? err : "alias could not be recorded")).c_str());
}
}
return 0;
}
/**
* @brief Removes a module's record and every alias resolving to it
*
* The module file stays where it is, so the module remains loadable
* by its own name and a listing reports it as uninstalled.
*/
int dpm_uninstall_module(dpm_ctx* ctx, const char* name) {
if (!ctx || !name || !*name) {
return 1;
}
std::string reason;
if (!dpm_core::md_remove_module_record(ctx, name, reason)) {
dpm_set_last_error(ctx, ("module '" + std::string(name) +
"': " + reason).c_str());
return 1;
}
// An alias outliving its module would resolve to a name the
// records no longer carry, so the module's aliases go with it.
bool removed_any = false;
for (auto it = ctx->aliases.begin(); it != ctx->aliases.end(); ) {
if (it->second == name) {
it = ctx->aliases.erase(it);
removed_any = true;
} else {
++it;
}
}
if (removed_any && !dpm_core::alias_write_table(ctx, reason)) {
dpm_set_last_error(ctx, ("module '" + std::string(name) +
"': " + reason).c_str());
return 1;
}
return 0;
}
/** /**
* @brief Advances an enumeration cursor * @brief Advances an enumeration cursor
* *

View File

@@ -45,4 +45,25 @@ namespace dpm_core {
} }
return s; return s;
} }
/**
* @brief Strips leading and trailing whitespace
*
* Applied to every line, key, and value read from a file on disk, so
* that surrounding spaces and the line's terminator never reach the
* value the library stores.
*/
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);
}
} // namespace dpm_core } // namespace dpm_core

View File

@@ -15,40 +15,62 @@ foreach(fixture good missing_symbols bad_version)
endforeach() endforeach()
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# API test binary — drives libdpm-core.so through its public C API # API test binaries — one per area of the library, each its own ctest
# case, so a failure names the area it happened in
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
add_executable(test_core test_core.cpp) foreach(suite context modules metadata aliases)
add_executable(test_${suite} test_${suite}.cpp)
target_link_libraries(test_core PRIVATE dpm-core) target_link_libraries(test_${suite} PRIVATE dpm-core)
target_compile_definitions(test_core PRIVATE target_include_directories(test_${suite} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_compile_definitions(test_${suite} PRIVATE
TEST_FIXTURE_MODULES="${DPM_TEST_FIXTURE_DIR}" TEST_FIXTURE_MODULES="${DPM_TEST_FIXTURE_DIR}"
TEST_FIXTURE_CONF="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf" TEST_FIXTURE_CONF="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf"
TEST_FIXTURE_METADATA="${CMAKE_CURRENT_SOURCE_DIR}/fixtures/metadata"
TEST_SCRATCH_ROOT="${DPM_TEST_DIR}/scratch"
DPM_CORE_VERSION_EXPECTED="${PROJECT_VERSION}" DPM_CORE_VERSION_EXPECTED="${PROJECT_VERSION}"
) )
set_target_properties(test_core PROPERTIES set_target_properties(test_${suite} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${DPM_TEST_DIR} RUNTIME_OUTPUT_DIRECTORY ${DPM_TEST_DIR}
BUILD_RPATH "$ORIGIN/../lib" BUILD_RPATH "$ORIGIN/../lib"
) )
foreach(fixture good missing_symbols bad_version) foreach(fixture good missing_symbols bad_version)
add_dependencies(test_core fixture_${fixture}) add_dependencies(test_${suite} fixture_${fixture})
endforeach()
add_test(NAME api_${suite} COMMAND test_${suite})
endforeach() endforeach()
add_test(NAME core_api COMMAND test_core)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
# CLI end-to-end tests (the dpm binary is the cheapest full-stack client) # CLI end-to-end tests (the dpm binary is the cheapest full-stack client)
# --------------------------------------------------------------------- # ---------------------------------------------------------------------
set(CLI_FIXTURE_ARGS
-c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf
-m ${CMAKE_BINARY_DIR}/modules
-M ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/metadata
)
add_test(NAME cli_help COMMAND dpm --help) add_test(NAME cli_help COMMAND dpm --help)
set_tests_properties(cli_help PROPERTIES PASS_REGULAR_EXPRESSION "Usage: dpm") set_tests_properties(cli_help PROPERTIES PASS_REGULAR_EXPRESSION "Usage: dpm")
add_test(NAME cli_list add_test(NAME cli_help_lists_record_flags COMMAND dpm --help)
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf set_tests_properties(cli_help_lists_record_flags PROPERTIES
-m ${CMAKE_BINARY_DIR}/modules --list-modules) PASS_REGULAR_EXPRESSION "--install-module")
add_test(NAME cli_list COMMAND dpm ${CLI_FIXTURE_ARGS} --list-modules)
set_tests_properties(cli_list PROPERTIES PASS_REGULAR_EXPRESSION "info") set_tests_properties(cli_list PROPERTIES PASS_REGULAR_EXPRESSION "info")
# The bundled module has no record in the fixture directory, so the
# listing reports it as uninstalled rather than opening it.
add_test(NAME cli_list_marks_uninstalled
COMMAND dpm ${CLI_FIXTURE_ARGS} --list-modules)
set_tests_properties(cli_list_marks_uninstalled PROPERTIES
PASS_REGULAR_EXPRESSION "<uninstalled>")
add_test(NAME cli_info_version add_test(NAME cli_info_version
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf
-m ${CMAKE_BINARY_DIR}/modules -L INFO info version) -m ${CMAKE_BINARY_DIR}/modules -L INFO info version)
@@ -56,11 +78,59 @@ set_tests_properties(cli_info_version PROPERTIES
PASS_REGULAR_EXPRESSION "libdpm-core\\.so Version: ${PROJECT_VERSION}") PASS_REGULAR_EXPRESSION "libdpm-core\\.so Version: ${PROJECT_VERSION}")
add_test(NAME cli_module_not_found add_test(NAME cli_module_not_found
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf COMMAND dpm ${CLI_FIXTURE_ARGS} nonexistent)
-m ${CMAKE_BINARY_DIR}/modules nonexistent)
set_tests_properties(cli_module_not_found PROPERTIES WILL_FAIL TRUE) set_tests_properties(cli_module_not_found PROPERTIES WILL_FAIL TRUE)
add_test(NAME cli_rejects_invalid_module add_test(NAME cli_rejects_invalid_module
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf
-m ${DPM_TEST_FIXTURE_DIR} missing_symbols) -m ${DPM_TEST_FIXTURE_DIR}
-M ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/metadata
missing_symbols)
set_tests_properties(cli_rejects_invalid_module PROPERTIES WILL_FAIL TRUE) set_tests_properties(cli_rejects_invalid_module PROPERTIES WILL_FAIL TRUE)
add_test(NAME cli_lists_aliases COMMAND dpm ${CLI_FIXTURE_ARGS} --list-aliases)
set_tests_properties(cli_lists_aliases PROPERTIES
PASS_REGULAR_EXPRESSION "goodie")
add_test(NAME cli_lists_aliases_for_module
COMMAND dpm ${CLI_FIXTURE_ARGS} --list-aliases good)
set_tests_properties(cli_lists_aliases_for_module PROPERTIES
PASS_REGULAR_EXPRESSION "gd")
add_test(NAME cli_refuses_duplicate_alias
COMMAND dpm ${CLI_FIXTURE_ARGS} --add-alias good goodie)
set_tests_properties(cli_refuses_duplicate_alias PROPERTIES WILL_FAIL TRUE)
add_test(NAME cli_add_alias_requires_two_arguments
COMMAND dpm ${CLI_FIXTURE_ARGS} --add-alias good)
set_tests_properties(cli_add_alias_requires_two_arguments PROPERTIES
WILL_FAIL TRUE)
add_test(NAME cli_uninstall_unrecorded_module
COMMAND dpm ${CLI_FIXTURE_ARGS} --uninstall-module nonexistent)
set_tests_properties(cli_uninstall_unrecorded_module PROPERTIES WILL_FAIL TRUE)
# The install and uninstall round trip writes, so it runs against a
# scratch directory of its own and the two cases run in order.
add_test(NAME cli_install_module
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf
-m ${CMAKE_BINARY_DIR}/modules
-M ${DPM_TEST_DIR}/scratch/cli
--install-module info)
add_test(NAME cli_lists_installed_module
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf
-m ${CMAKE_BINARY_DIR}/modules
-M ${DPM_TEST_DIR}/scratch/cli
--list-modules)
set_tests_properties(cli_lists_installed_module PROPERTIES
PASS_REGULAR_EXPRESSION "Reports and tests libdpm-core"
DEPENDS cli_install_module)
add_test(NAME cli_uninstall_module
COMMAND dpm -c ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/conf
-m ${CMAKE_BINARY_DIR}/modules
-M ${DPM_TEST_DIR}/scratch/cli
--uninstall-module info)
set_tests_properties(cli_uninstall_module PROPERTIES
DEPENDS cli_lists_installed_module)

2
tests/fixtures/metadata/good.meta vendored Normal file
View File

@@ -0,0 +1,2 @@
version = 1.2.3
description = Known-good stub module.

View File

@@ -0,0 +1,2 @@
goodie = good
gd = good

View File

@@ -28,6 +28,10 @@ extern "C" const char* dpm_module_description(void) {
return "Fixture with a malformed version."; return "Fixture with a malformed version.";
} }
extern "C" const char* dpm_module_aliases(void) {
return nullptr;
}
extern "C" int dpm_module_execute(void* ctx, const char* command, extern "C" int dpm_module_execute(void* ctx, const char* command,
int argc, char** argv) { int argc, char** argv) {
(void)ctx; (void)ctx;

View File

@@ -32,6 +32,12 @@ extern "C" const char* dpm_module_description(void) {
return "Known-good stub module."; return "Known-good stub module.";
} }
/* Declares two alternate names, so alias recording is exercised against
a module that reports more than one. */
extern "C" const char* dpm_module_aliases(void) {
return "goodie, gd";
}
/* Answers "ping" with 42 so a dispatch round trip is observable, and /* Answers "ping" with 42 so a dispatch round trip is observable, and
0 for anything else. */ 0 for anything else. */
extern "C" int dpm_module_execute(void* ctx, const char* command, extern "C" int dpm_module_execute(void* ctx, const char* command,

View File

@@ -2,8 +2,9 @@
* @file missing_symbols.cpp * @file missing_symbols.cpp
* @brief Broken fixture: exports only part of the module contract * @brief Broken fixture: exports only part of the module contract
* *
* Exports the version and description probes and nothing else. * Exports the version and description probes and nothing else, so two
* libdpm-core.so refuses it at step 1 and names dpm_module_execute. * of the four reserved contract symbols are absent. libdpm-core.so
* refuses it at step 1 and names both.
* *
* Part of the Dark Horse Linux Package Manager (DPM) * Part of the Dark Horse Linux Package Manager (DPM)
* *

87
tests/harness.hpp Normal file
View File

@@ -0,0 +1,87 @@
/**
* @file harness.hpp
* @brief What every test binary shares: counting, reporting, fixture paths
*
* Each test binary covers one area of the library and is registered as
* its own ctest case, so a failure names the area it happened in. This
* header carries what all of them need and nothing specific to any one.
*
* @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/>.
*/
#pragma once
#include <dpm/core.h>
#include <cstdio>
#include <cstring>
#ifndef TEST_FIXTURE_MODULES
#error "TEST_FIXTURE_MODULES must be defined"
#endif
#ifndef TEST_FIXTURE_CONF
#error "TEST_FIXTURE_CONF must be defined"
#endif
#ifndef TEST_FIXTURE_METADATA
#error "TEST_FIXTURE_METADATA must be defined"
#endif
#ifndef TEST_SCRATCH_ROOT
#error "TEST_SCRATCH_ROOT must be defined"
#endif
/** Assertions run and assertions failed, reported by harness_report(). */
static int g_checks = 0;
static int g_failures = 0;
/**
* @brief Records one assertion and reports it when it fails
*
* Every failure names the file and line, so a ctest case that fails
* points at the assertion rather than at the binary.
*/
#define CHECK(cond) \
do { \
g_checks++; \
if (!(cond)) { \
g_failures++; \
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \
#cond); \
} \
} while (0)
/**
* @brief Reports whether the context's last error carries a substring
*
* @param ctx The context to read
* @param needle The text the reason is expected to contain
* @return true when a reason is recorded and contains needle
*/
static inline bool error_contains(dpm_ctx* ctx, const char* needle) {
const char* err = dpm_get_last_error(ctx);
return err != nullptr && std::strstr(err, needle) != nullptr;
}
/**
* @brief Prints the tally and yields the process exit status
*
* @return 0 when every assertion passed, 1 otherwise
*/
static inline int harness_report(const char* suite) {
std::printf("%s: %d checks, %d failures\n", suite, g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}

197
tests/test_aliases.cpp Normal file
View File

@@ -0,0 +1,197 @@
/**
* @file test_aliases.cpp
* @brief Alternate module names: resolution, recording, and enumeration
*
* @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 "harness.hpp"
#include <filesystem>
#include <string>
namespace fs = std::filesystem;
int main(void) {
const std::string scratch = std::string(TEST_SCRATCH_ROOT) + "/aliases";
/* ---- a recorded alias reaches the module it names ---- */
{
dpm_open_overrides overrides = {TEST_FIXTURE_CONF,
TEST_FIXTURE_MODULES, nullptr, -1,
TEST_FIXTURE_METADATA};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
std::fprintf(stderr, "cannot continue without a context\n");
return 1;
}
dpm_module* by_alias = dpm_require(ctx, "goodie");
CHECK(by_alias != nullptr);
dpm_module_info seen;
if (by_alias) {
CHECK(dpm_get_module_info(ctx, by_alias, &seen) == 0);
CHECK(std::strcmp(seen.name, "good") == 0);
}
/* the alias and the real name reach the same load */
CHECK(dpm_require(ctx, "good") == by_alias);
CHECK(dpm_require(ctx, "gd") == by_alias);
/* a name that is neither a module nor an alias stays unresolved */
CHECK(dpm_require(ctx, "nosuchname") == nullptr);
dpm_close(ctx);
}
/* ---- enumeration reports every alias, or one module's ---- */
{
dpm_open_overrides overrides = {TEST_FIXTURE_CONF,
TEST_FIXTURE_MODULES, nullptr, -1,
TEST_FIXTURE_METADATA};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
return 1;
}
dpm_alias_cursor* cur = dpm_list_module_aliases(ctx, nullptr);
CHECK(cur != nullptr);
if (cur) {
int count = 0;
dpm_alias_info info;
while (dpm_alias_cursor_next(cur, &info) == 0) {
count++;
CHECK(info.alias != nullptr && *info.alias);
CHECK(std::strcmp(info.module, "good") == 0);
}
CHECK(count == 2);
CHECK(dpm_alias_cursor_next(cur, &info) != 0);
dpm_alias_cursor_free(cur);
}
cur = dpm_list_module_aliases(ctx, "good");
CHECK(cur != nullptr);
if (cur) {
int count = 0;
dpm_alias_info info;
while (dpm_alias_cursor_next(cur, &info) == 0) {
count++;
}
CHECK(count == 2);
dpm_alias_cursor_free(cur);
}
cur = dpm_list_module_aliases(ctx, "nosuchmodule");
CHECK(cur != nullptr);
if (cur) {
dpm_alias_info info;
CHECK(dpm_alias_cursor_next(cur, &info) != 0);
dpm_alias_cursor_free(cur);
}
CHECK(dpm_list_module_aliases(nullptr, nullptr) == nullptr);
CHECK(dpm_alias_cursor_next(nullptr, nullptr) != 0);
dpm_alias_cursor_free(nullptr);
dpm_close(ctx);
}
/* ---- recording and removing names against a writable directory ---- */
{
fs::remove_all(scratch);
dpm_open_overrides overrides = {TEST_FIXTURE_CONF,
TEST_FIXTURE_MODULES, nullptr, -1,
scratch.c_str()};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
return 1;
}
/* installing records what the module declared */
CHECK(dpm_install_module(ctx, "good") == 0);
CHECK(fs::exists(scratch + "/modules.aliases"));
dpm_alias_cursor* cur = dpm_list_module_aliases(ctx, "good");
CHECK(cur != nullptr);
if (cur) {
int count = 0;
dpm_alias_info info;
while (dpm_alias_cursor_next(cur, &info) == 0) {
count++;
}
CHECK(count == 2);
dpm_alias_cursor_free(cur);
}
/* a name already recorded is refused rather than repointed */
CHECK(dpm_add_module_alias(ctx, "good", "goodie") != 0);
CHECK(error_contains(ctx, "already resolves"));
/* an installed module's own name is not available */
CHECK(dpm_add_module_alias(ctx, "good", "good") != 0);
CHECK(error_contains(ctx, "installed module"));
/* bad arguments are refused */
CHECK(dpm_add_module_alias(nullptr, "good", "x") != 0);
CHECK(dpm_add_module_alias(ctx, nullptr, "x") != 0);
CHECK(dpm_add_module_alias(ctx, "good", nullptr) != 0);
CHECK(dpm_add_module_alias(ctx, "good", "") != 0);
/* a free name is recorded and resolves in a later context */
CHECK(dpm_add_module_alias(ctx, "good", "gg") == 0);
dpm_ctx* reader = dpm_open(&overrides);
CHECK(reader != nullptr);
if (reader) {
CHECK(dpm_require(reader, "gg") != nullptr);
dpm_close(reader);
}
/* removal takes the name back out */
CHECK(dpm_remove_module_alias(ctx, "gg") == 0);
CHECK(dpm_remove_module_alias(ctx, "gg") != 0);
CHECK(error_contains(ctx, "not recorded"));
CHECK(dpm_remove_module_alias(ctx, nullptr) != 0);
CHECK(dpm_remove_module_alias(nullptr, "gg") != 0);
/* uninstalling a module takes its aliases with it */
CHECK(dpm_uninstall_module(ctx, "good") == 0);
dpm_ctx* after = dpm_open(&overrides);
CHECK(after != nullptr);
if (after) {
dpm_alias_cursor* empty = dpm_list_module_aliases(after, nullptr);
CHECK(empty != nullptr);
if (empty) {
dpm_alias_info info;
CHECK(dpm_alias_cursor_next(empty, &info) != 0);
dpm_alias_cursor_free(empty);
}
dpm_close(after);
}
dpm_close(ctx);
}
return harness_report("aliases");
}

122
tests/test_context.cpp Normal file
View File

@@ -0,0 +1,122 @@
/**
* @file test_context.cpp
* @brief Context lifecycle, override validation, and configuration reading
*
* @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 "harness.hpp"
int main(void) {
/* ---- an explicit override that cannot work refuses at open ---- */
{
dpm_open_overrides bad_conf = {"/does/not/exist/conf", nullptr,
nullptr, -1, nullptr};
CHECK(dpm_open(&bad_conf) == nullptr);
dpm_open_overrides bad_level = {nullptr, nullptr, nullptr, 99, nullptr};
CHECK(dpm_open(&bad_level) == nullptr);
dpm_open_overrides low_level = {nullptr, nullptr, nullptr, -2, nullptr};
CHECK(dpm_open(&low_level) == nullptr);
}
/* ---- a NULL overrides pointer opens against system defaults ---- */
{
dpm_ctx* system_ctx = dpm_open(nullptr);
CHECK(system_ctx != nullptr);
if (system_ctx) {
const char* path = dpm_get_resolved_module_path(system_ctx);
CHECK(path != nullptr && *path);
dpm_close(system_ctx);
}
}
/* ---- NULL is a no-op everywhere a context is expected ---- */
{
dpm_close(nullptr);
dpm_log(nullptr, DPM_LOG_INFO, "dropped");
CHECK(dpm_get_resolved_module_path(nullptr) == nullptr);
CHECK(dpm_get_last_error(nullptr) == nullptr);
CHECK(dpm_config_get(nullptr, "core", "modules", "path") == nullptr);
}
dpm_open_overrides overrides = {TEST_FIXTURE_CONF, TEST_FIXTURE_MODULES,
nullptr, -1, TEST_FIXTURE_METADATA};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
std::fprintf(stderr, "cannot continue without a context\n");
return 1;
}
/* ---- the version the library reports is the one it was built as ---- */
CHECK(std::strcmp(dpm_core_version(), DPM_CORE_VERSION_EXPECTED) == 0);
/* ---- configuration resolves per namespace, per section ---- */
{
const char* v = dpm_config_get(ctx, "testmod", "main", "key");
CHECK(v != nullptr && std::strcmp(v, "value") == 0);
v = dpm_config_get(ctx, "testmod", "section2", "other");
CHECK(v != nullptr && std::strcmp(v, "42") == 0);
CHECK(dpm_config_get(ctx, "testmod", "main", "absent") == nullptr);
CHECK(dpm_config_get(ctx, "testmod", "nosection", "key") == nullptr);
CHECK(dpm_config_get(ctx, "nomodule", "main", "key") == nullptr);
CHECK(dpm_config_get(ctx, nullptr, "main", "key") == nullptr);
}
/* ---- an override beats the value configuration supplied ---- */
{
const char* configured =
dpm_config_get(ctx, "core", "modules", "path");
CHECK(configured != nullptr);
const char* path = dpm_get_resolved_module_path(ctx);
CHECK(path != nullptr);
CHECK(path != nullptr &&
std::strncmp(path, TEST_FIXTURE_MODULES,
std::strlen(TEST_FIXTURE_MODULES)) == 0);
}
/* ---- a resolved directory carries a trailing separator ---- */
{
const char* path = dpm_get_resolved_module_path(ctx);
CHECK(path != nullptr && *path && path[std::strlen(path) - 1] == '/');
}
/* ---- the error slot starts empty and holds what was last written --- */
{
CHECK(dpm_get_last_error(ctx) == nullptr);
dpm_set_last_error(ctx, "first reason");
CHECK(error_contains(ctx, "first reason"));
dpm_set_last_error(ctx, "second reason");
CHECK(error_contains(ctx, "second reason"));
CHECK(!error_contains(ctx, "first reason"));
dpm_set_last_error(ctx, nullptr);
CHECK(error_contains(ctx, "second reason"));
dpm_set_last_error(nullptr, "ignored");
}
dpm_close(ctx);
return harness_report("context");
}

View File

@@ -1,169 +0,0 @@
/**
* @file test_core.cpp
* @brief Test binary: drives libdpm-core through its public C API
*
* Exercises context lifecycle, configuration, the full load-time
* validation matrix against the fixture modules, require, dispatch,
* and enumeration. Exits nonzero on any failure.
*
* @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 <dpm/core.h>
#include <cstdio>
#include <cstring>
#include <string>
#ifndef TEST_FIXTURE_MODULES
#error "TEST_FIXTURE_MODULES must be defined"
#endif
#ifndef TEST_FIXTURE_CONF
#error "TEST_FIXTURE_CONF must be defined"
#endif
static int g_failures = 0;
static int g_checks = 0;
#define CHECK(cond) \
do { \
g_checks++; \
if (!(cond)) { \
g_failures++; \
std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, \
#cond); \
} \
} while (0)
static bool error_contains(dpm_ctx* ctx, const char* needle) {
const char* err = dpm_get_last_error(ctx);
return err != nullptr && std::strstr(err, needle) != nullptr;
}
int main(void) {
/* ---- dpm_open: invalid explicit override refuses ---- */
{
dpm_open_overrides bad = {"/does/not/exist/conf", nullptr, nullptr, -1};
CHECK(dpm_open(&bad) == nullptr);
dpm_open_overrides bad_level = {nullptr, nullptr, nullptr, 99};
CHECK(dpm_open(&bad_level) == nullptr);
}
/* ---- context with fixture config and fixture module path ---- */
dpm_open_overrides overrides = {TEST_FIXTURE_CONF, TEST_FIXTURE_MODULES,
nullptr, -1};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
std::fprintf(stderr, "cannot continue without a context\n");
return 1;
}
/* ---- services ---- */
{
CHECK(std::strcmp(dpm_core_version(), DPM_CORE_VERSION_EXPECTED) == 0);
/* config: values resolve per-module, per-section */
const char* v = dpm_config_get(ctx, "testmod", "main", "key");
CHECK(v != nullptr && std::strcmp(v, "value") == 0);
v = dpm_config_get(ctx, "testmod", "section2", "other");
CHECK(v != nullptr && std::strcmp(v, "42") == 0);
CHECK(dpm_config_get(ctx, "testmod", "main", "absent") == nullptr);
CHECK(dpm_config_get(ctx, "nomodule", "main", "key") == nullptr);
/* module-path override wins over the config value */
const char* path = dpm_get_resolved_module_path(ctx);
CHECK(path != nullptr &&
std::strncmp(path, TEST_FIXTURE_MODULES,
std::strlen(TEST_FIXTURE_MODULES)) == 0);
}
/* ---- validation matrix: every broken fixture refused, precisely ---- */
{
CHECK(dpm_require(ctx, "missing_symbols") == nullptr);
CHECK(error_contains(ctx, "missing required contract symbols"));
CHECK(error_contains(ctx, "dpm_module_execute"));
CHECK(dpm_require(ctx, "bad_version") == nullptr);
CHECK(error_contains(ctx, "malformed version"));
CHECK(dpm_require(ctx, "nonexistent") == nullptr);
CHECK(error_contains(ctx, "not found"));
}
/* ---- known-good module: require, versions, api, execute ---- */
{
dpm_module* good = dpm_require(ctx, "good");
CHECK(good != nullptr);
/* loaded at most once per context */
CHECK(dpm_require(ctx, "good") == good);
/* libdpm-core reports what it saw; the caller judges compatibility */
dpm_module_info seen;
CHECK(dpm_get_module_info(ctx, good, &seen) == 0);
CHECK(std::strcmp(seen.name, "good") == 0);
CHECK(std::strcmp(seen.version, "1.2.3") == 0);
CHECK(seen.description != nullptr && *seen.description);
CHECK(dpm_get_module_info(ctx, nullptr, &seen) != 0);
CHECK(dpm_get_module_info(ctx, good, nullptr) != 0);
/* dispatch: the module's return value comes back verbatim, so a
command round trip is observable without any compile-time
knowledge of the module */
CHECK(dpm_execute(ctx, good, "ping", 0, nullptr) == 42);
CHECK(dpm_execute(ctx, good, "anything_else", 0, nullptr) == 0);
CHECK(dpm_execute(ctx, good, nullptr, 0, nullptr) == 0);
}
/* ---- enumeration: only the valid module surfaces ---- */
{
dpm_cursor* cur = dpm_list_modules(ctx);
CHECK(cur != nullptr);
if (cur) {
int count = 0;
dpm_module_info info;
while (dpm_cursor_next(cur, &info) == 0) {
count++;
CHECK(std::strcmp(info.name, "good") == 0);
CHECK(std::strcmp(info.version, "1.2.3") == 0);
CHECK(info.description != nullptr && *info.description);
}
CHECK(count == 1);
dpm_cursor_free(cur);
}
/* unreadable module path refuses with a reason */
dpm_open_overrides bad_path = {TEST_FIXTURE_CONF, "/does/not/exist",
nullptr, -1};
dpm_ctx* ctx2 = dpm_open(&bad_path);
CHECK(ctx2 != nullptr);
if (ctx2) {
CHECK(dpm_list_modules(ctx2) == nullptr);
CHECK(error_contains(ctx2, "module path"));
dpm_close(ctx2);
}
}
dpm_close(ctx);
std::printf("%d checks, %d failures\n", g_checks, g_failures);
return g_failures == 0 ? 0 : 1;
}

194
tests/test_metadata.cpp Normal file
View File

@@ -0,0 +1,194 @@
/**
* @file test_metadata.cpp
* @brief Module records: installation, removal, and listing without loading
*
* @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 "harness.hpp"
#include <filesystem>
#include <string>
namespace fs = std::filesystem;
int main(void) {
const std::string scratch = std::string(TEST_SCRATCH_ROOT) + "/metadata";
/* ---- listing reads records and marks what has none ---- */
{
dpm_open_overrides overrides = {TEST_FIXTURE_CONF,
TEST_FIXTURE_MODULES, nullptr, -1,
TEST_FIXTURE_METADATA};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
std::fprintf(stderr, "cannot continue without a context\n");
return 1;
}
dpm_cursor* cur = dpm_list_modules(ctx);
CHECK(cur != nullptr);
if (cur) {
int count = 0;
int uninstalled = 0;
bool saw_good = false;
dpm_module_info info;
while (dpm_cursor_next(cur, &info) == 0) {
count++;
CHECK(info.name != nullptr);
CHECK(info.version != nullptr);
CHECK(info.description != nullptr);
if (std::strcmp(info.name, "good") == 0) {
saw_good = true;
CHECK(std::strcmp(info.version, "1.2.3") == 0);
CHECK(*info.description);
}
if (std::strcmp(info.version, "<uninstalled>") == 0) {
uninstalled++;
}
}
/* every .so present is reported, recorded or not */
CHECK(count == 3);
CHECK(saw_good);
CHECK(uninstalled == 2);
dpm_cursor_free(cur);
}
/* the cursor reports nothing further once exhausted */
cur = dpm_list_modules(ctx);
CHECK(cur != nullptr);
if (cur) {
dpm_module_info info;
while (dpm_cursor_next(cur, &info) == 0) {
}
CHECK(dpm_cursor_next(cur, &info) != 0);
dpm_cursor_free(cur);
}
CHECK(dpm_cursor_next(nullptr, nullptr) != 0);
dpm_cursor_free(nullptr);
/* an unreadable module path refuses with a reason */
dpm_open_overrides bad_path = {TEST_FIXTURE_CONF, "/does/not/exist",
nullptr, -1, TEST_FIXTURE_METADATA};
dpm_ctx* bad = dpm_open(&bad_path);
CHECK(bad != nullptr);
if (bad) {
CHECK(dpm_list_modules(bad) == nullptr);
CHECK(error_contains(bad, "module path"));
dpm_close(bad);
}
CHECK(dpm_list_modules(nullptr) == nullptr);
dpm_close(ctx);
}
/* ---- installation writes a record a later context reads back ---- */
{
fs::remove_all(scratch);
dpm_open_overrides overrides = {TEST_FIXTURE_CONF,
TEST_FIXTURE_MODULES, nullptr, -1,
scratch.c_str()};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
std::fprintf(stderr, "cannot continue without a context\n");
return 1;
}
/* a module that cannot load cannot be installed */
CHECK(dpm_install_module(ctx, "bad_version") != 0);
CHECK(error_contains(ctx, "malformed version"));
CHECK(dpm_install_module(ctx, "nonexistent") != 0);
CHECK(dpm_install_module(ctx, nullptr) != 0);
CHECK(dpm_install_module(nullptr, "good") != 0);
CHECK(dpm_install_module(ctx, "good") == 0);
CHECK(fs::exists(scratch + "/good.meta"));
/* the record answers a fresh context without opening the module */
dpm_ctx* reader = dpm_open(&overrides);
CHECK(reader != nullptr);
if (reader) {
dpm_cursor* cur = dpm_list_modules(reader);
CHECK(cur != nullptr);
if (cur) {
bool found = false;
dpm_module_info info;
while (dpm_cursor_next(cur, &info) == 0) {
if (std::strcmp(info.name, "good") == 0) {
found = true;
CHECK(std::strcmp(info.version, "1.2.3") == 0);
CHECK(std::strcmp(info.description,
"Known-good stub module.") == 0);
}
}
CHECK(found);
dpm_cursor_free(cur);
}
dpm_close(reader);
}
/* installing again over an existing record succeeds */
CHECK(dpm_install_module(ctx, "good") == 0);
/* removal takes the record and leaves the module file alone */
CHECK(dpm_uninstall_module(ctx, "good") == 0);
CHECK(!fs::exists(scratch + "/good.meta"));
CHECK(dpm_uninstall_module(ctx, "good") != 0);
CHECK(dpm_uninstall_module(ctx, nullptr) != 0);
CHECK(dpm_uninstall_module(nullptr, "good") != 0);
/* the module still loads by its own name once uninstalled */
dpm_ctx* after = dpm_open(&overrides);
CHECK(after != nullptr);
if (after) {
CHECK(dpm_require(after, "good") != nullptr);
dpm_close(after);
}
dpm_close(ctx);
}
/* ---- an unwritable metadata directory fails without throwing ---- */
{
dpm_open_overrides readonly = {TEST_FIXTURE_CONF, TEST_FIXTURE_MODULES,
nullptr, -1, "/proc/dpm-metadata"};
dpm_ctx* ctx = dpm_open(&readonly);
CHECK(ctx != nullptr);
if (ctx) {
CHECK(dpm_install_module(ctx, "good") != 0);
/* listing and loading carry on with no records available */
dpm_cursor* cur = dpm_list_modules(ctx);
CHECK(cur != nullptr);
if (cur) {
dpm_cursor_free(cur);
}
CHECK(dpm_require(ctx, "good") != nullptr);
dpm_close(ctx);
}
}
return harness_report("metadata");
}

90
tests/test_modules.cpp Normal file
View File

@@ -0,0 +1,90 @@
/**
* @file test_modules.cpp
* @brief Load-time validation, module acquisition, and dispatch
*
* @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 "harness.hpp"
int main(void) {
dpm_open_overrides overrides = {TEST_FIXTURE_CONF, TEST_FIXTURE_MODULES,
nullptr, -1, TEST_FIXTURE_METADATA};
dpm_ctx* ctx = dpm_open(&overrides);
CHECK(ctx != nullptr);
if (!ctx) {
std::fprintf(stderr, "cannot continue without a context\n");
return 1;
}
/* ---- every broken fixture is refused, and the reason says why ---- */
{
CHECK(dpm_require(ctx, "missing_symbols") == nullptr);
CHECK(error_contains(ctx, "missing required contract symbols"));
CHECK(error_contains(ctx, "dpm_module_execute"));
CHECK(error_contains(ctx, "dpm_module_aliases"));
CHECK(dpm_require(ctx, "bad_version") == nullptr);
CHECK(error_contains(ctx, "malformed version"));
CHECK(dpm_require(ctx, "nonexistent") == nullptr);
CHECK(error_contains(ctx, "not found"));
}
/* ---- a bad argument is refused without touching the module path ---- */
{
CHECK(dpm_require(nullptr, "good") == nullptr);
CHECK(dpm_require(ctx, nullptr) == nullptr);
CHECK(dpm_require(ctx, "") == nullptr);
}
/* ---- the known-good fixture loads and reports what it declares ---- */
{
dpm_module* good = dpm_require(ctx, "good");
CHECK(good != nullptr);
if (!good) {
dpm_close(ctx);
return harness_report("modules");
}
/* a module is opened at most once per context */
CHECK(dpm_require(ctx, "good") == good);
dpm_module_info seen;
CHECK(dpm_get_module_info(ctx, good, &seen) == 0);
CHECK(std::strcmp(seen.name, "good") == 0);
CHECK(std::strcmp(seen.version, "1.2.3") == 0);
CHECK(seen.description != nullptr && *seen.description);
CHECK(dpm_get_module_info(ctx, nullptr, &seen) != 0);
CHECK(dpm_get_module_info(ctx, good, nullptr) != 0);
CHECK(dpm_get_module_info(nullptr, good, &seen) != 0);
/* dispatch returns the module's own value, so a round trip is
observable with no compile-time knowledge of the module */
CHECK(dpm_execute(ctx, good, "ping", 0, nullptr) == 42);
CHECK(dpm_execute(ctx, good, "anything_else", 0, nullptr) == 0);
CHECK(dpm_execute(ctx, good, nullptr, 0, nullptr) == 0);
CHECK(dpm_execute(nullptr, good, "ping", 0, nullptr) == 1);
CHECK(dpm_execute(ctx, nullptr, "ping", 0, nullptr) == 1);
}
dpm_close(ctx);
return harness_report("modules");
}