Files
logos-liblogos/tests/test_module_manager.cpp
T
Dario LipicarandClaude Opus 5 93207e4141 chore(deps): track protocol and plugin-qt master (#177)
* fix(windows): point the shared-runtime .def at logos-qt-host, and fail loudly

The Windows single-provider scheme makes liblogos_core.dll the one provider of
the shared C++ runtime, and it names its inputs by CMake TARGET. logos-qt-sdk no
longer carries an archive — the Qt host runtime moved to logos-qt-host — so
$<TARGET_FILE:logos-qt-sdk::logos_qt_sdk> no longer resolves and the whole
mechanism had to be repointed.

More importantly, the old shape failed OPEN. The guard was

  if(WIN32 AND TARGET logos-protocol::... AND TARGET logos-qt-sdk::logos_qt_sdk)

so a missing target did not error — the condition simply went false and the
--whole-archive link, the nm scan and the generated .def were all skipped
SILENTLY. The result is the split-brain this file exists to prevent: main_ui and
ui-host each end up with their own TokenManager and every cross-module call is
refused. The code already fails loudly when `nm` is missing, for exactly this
reason; it had no equivalent guard for the target being absent.

Now the target tests are a foreach + FATAL_ERROR inside if(WIN32), so a missing
provider stops the configure instead of quietly restoring the split-brain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(access-policy): say ON/OFF out loud, and pin the flag both directions

Deny-by-default enforcement already existed here: `mode: "enforce"` is the
switch, and under it computeDerivedAllowedCallersLocked derives each target's
allowed callers from the declared dependency graph. Nothing about that
changes — this makes the switch legible and pins its contract.

setAccessPolicy now states which side it landed on for every input (no
policy / unparseable / non-enforce mode / enforce). Enforcement that silently
failed to arm is the dangerous outcome: it looks identical to enforcement
that is working and simply has nothing to deny, so an operator who mistyped
`"mode":"enforced"` previously got a wide-open runtime and a clean log.

DenyByDefaultFlagTest drives one scenario through the flip: `declared`
declares `target`, `undeclared` declares nothing. Flag off, the target has no
restriction at all (today's behaviour). Flag on, `declared` is on the list and
`undeclared` is not. The declared half carries the weight — an implementation
that refused everything would pass the denial half on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(b4): link the Qt host runtime from logos-qt-host, not logos-qt-sdk

B1 moved LogosAPI / LogosAPIProvider / LogosProviderBase / PluginInterface
out of logos-qt-sdk into logos-plugin-qt, published as the CMake target
logos-qt-host::logos_qt_host. This repoints liblogos at that target so B2b
can delete logos-qt-sdk's forwarders.

The host runtime is found DIRECTLY: add logos-plugin-qt as an input (pinned
to the B1 rev, since logos-qt-host is not on its master yet) with
logos-protocol / logos-nix / nixpkgs following, and find_package it against
a new LOGOS_QT_HOST_ROOT. It is deliberately not inherited through
find_package(logos-qt-sdk) -- qt-sdk does not carry a dependency on the host
runtime today, and will carry even less of one after B2b.

That also repairs the Windows single-provider block. It already named
logos-qt-host::logos_qt_host, but its comment claimed the target arrived via
find_package(logos-qt-sdk), which was false -- so its FATAL_ERROR guard would
have fired on the first real Windows build. The guard is unchanged (still a
hard error, never a silent skip); its premise is now true.

logos-qt-sdk stays an input, for the developer headers nix/include.nix
re-exports (logos_ui_plugin_context.h and friends) -- not for the host
runtime. Its now-unused -DLOGOS_QT_SDK_ROOT flag is dropped so CMake does not
warn about an unused variable; the env entry stays.

Two silent skips converted to hard errors along the way:

  - tests/CMakeLists.txt guarded its SDK include dirs with `if(EXISTS ...)`,
    so a bad root compiled the tests against a different copy of LogosAPI
    than they link. Now FATAL.
  - nix/include.nix copies the host headers over the qt-sdk ones so consumers
    of this prefix see the declaration liblogos_core actually links (qt-host's
    carries LOGOS_SHARED_API, the dllimport that keeps Windows on one
    TokenManager). Everything copied before it is mode 0444 out of the store,
    so a plain `cp -r` fails with EACCES and the existing `|| true` would have
    swallowed it -- hence chmod + `cp -rf`, plus an assertion that the
    installed logos_api.h really is qt-host's.

Verified on aarch64-darwin with local overrides for cpp-sdk, qt-sdk, protocol
and plugin-qt: logos-liblogos-lib, -include, -tests, default and portable all
build; the `tests` check runs 185 tests, 0 failures. Pointing
LOGOS_QT_HOST_ROOT at a nonexistent path fails the build with the intended
FATAL_ERROR rather than falling back. The header prefix is a strict superset
of the previous one (58 -> 63 files, none removed).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(deps): raise logos-protocol to the rev logos-qt-host actually needs

b3b2e50 repointed this repo from logos-qt-sdk's archive to logos-plugin-qt's
logos-qt-host, but left logos-protocol on master (03842db). That made the
repoint INERT: the tree did not build at all.

    cpp/logos_api.cpp:38:52: error: no member named 'forIdentity' in 'TokenManager'
    cpp/logos_api.cpp:48:24: error: no member named 'isolateIdentity' in 'TokenManager'
    cpp/logos_api.cpp:57:50: error: no member named 'forIdentity' in 'TokenManager'

logos-qt-host's LogosAPI is built on the per-identity token store, and protocol
master has none of it -- 03842db's TokenManager carries only instance(),
m_tokens and m_mutex. The identity API arrives in c8bab12
(feat/per-client-token-store), so that is the floor for consuming qt-host at
all. The rev is pinned in the url, not just the lock, for the same reason
logos-plugin-qt already is: it is not on master, so a bare url would let
`nix flake update` silently walk this back to three compile errors.

The rev is also not merely "new enough". It is the SAME rev logos-basecamp and
logos-standalone-app pin, and that identity is the point. liblogos_core, the
app image and every in-process UI plugin share one TokenManager; two protocol
generations across that boundary give two token stores, which is the
"ModuleProxy: rejecting unauthorized call ... auth token not recognized"
failure the Windows .def block in src/CMakeLists.txt exists to prevent. On PE
that shows up as duplicate definitions; on Mach-O the second store is simply
linked into whichever image referenced a symbol liblogos_core failed to export.
Which is exactly what was happening here: with no forIdentity to import, a
consumer drags logos_api.cpp.o out of the static archive and token_manager.cpp.o
comes with it.

Only logos-protocol moves. logos-cpp-sdk, logos-qt-sdk, logos-plugin-qt and
default-module-loader already `follows` it, so all four now compile against
c8bab12 and the lock diff is one node.

Verified on aarch64-darwin, every package and check built by name, all EXIT=0:
default, logos-liblogos, -bin, -include, -lib, -modules, -tests, portable, and
the `tests` check -- 185 tests, 0 failures. The acceptance measurement on the
built library:

    nm -gU lib/liblogos_core.dylib | grep -c LogosAPI11forIdentity   -> 1  (was: no build)
    nm -gU lib/liblogos_core.dylib | grep -c TokenManager8instanceEv -> 1  (still the provider)

liblogos_core now exports the whole identity surface -- LogosAPI::forIdentity,
TokenManager::forIdentity / isolateIdentity / isIsolated / seedBootstrapTokens
-- so consumers import them instead of re-linking a second copy.

NOT fixed here, and blocking on other repos:

  - packages.x86_64-windows does not evaluate on this branch:
    `attribute 'x86_64-windows' missing` at logos-plugin-qt.packages.<system>.
    logos-qt-host. logos-qt-sdk exposes a windows pseudo-system via
    forAllTargets/mkWindowsPkgs; logos-plugin-qt has only forAllSystems over the
    four real systems. So b3b2e50 traded a Windows-capable provider for one that
    is not, and the Windows single-provider machinery this repo owns cannot be
    evaluated, let alone measured, until logos-plugin-qt grows that target.
    liblogos master (5035877) still evaluates it.

  - the consumer half of the single-provider scheme still fails open:
    logos-basecamp/cmake/LogosSharedFromDll.cmake's
    logos_use_shared_runtime_from_dll() skips names that are not targets with no
    else, so a rename there is a silent no-op rather than an error. Its callers
    pass the right names today, so the hazard is latent rather than active.
    logos-qt-sdk's comment claims that file is duplicated into
    logos-logoscore-cli; at that repo's current HEAD (df31c82) it is not --
    there are no .cmake files there at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): raise logos-plugin-qt to the pushed qt-host branch tip

8ccb1fc -> cc24fa1c, the tip of logos-plugin-qt's
feat/b4-qt-host-windows-target, which is now on origin.

This is not a routine refresh. 8ccb1fc keyed `packages` off forAllSystems and
so had no x86_64-windows attribute at all, while this flake reads
logos-plugin-qt.packages.${system}.logos-qt-host from forAllTargets. Against
the old rev, packages.x86_64-windows.default did not merely fail to build, it
failed to EVALUATE:

    error: attribute 'x86_64-windows' missing
    at flake.nix:145:25
        logosQtHost = logos-plugin-qt.packages.${system}.logos-qt-host;

Against cc24fa1c it evaluates to a derivation. Both directions were confirmed
with --override-input rather than assumed.

cc24fa1c rather than the sibling feat/b4-qt-host-windows-target-8ccb1fc
(989f6ae): the two branches carry the same work and their nix/qt-host.nix is
byte-identical, so they build the same runtime. The tiebreak is that
logos-qt-sdk pins cc24fa1c. This flake deliberately does not make
logos-qt-sdk's logos-plugin-qt follow this one, so pinning the other tip would
put two logos-qt-host builds in a single closure -- two LogosAPI/TokenManager
copies in one process, which is exactly the split-brain the .def block in
src/CMakeLists.txt exists to prevent.

Nothing else in the lock moved, deliberately. logos-protocol was already at
c8bab12; logos-cpp-sdk, logos-qt-sdk and logos-capability-module stay on
master, which is what this branch was written against -- include.nix already
copies qt-host's headers OVER qt-sdk's forwarders and asserts on
LOGOS_SHARED_API, so the surviving forwarders are handled rather than merely
tolerated. That assertion passes.

Verified on aarch64-darwin: all 8 packages plus checks.tests build, exit 0
each; the test suite reports 185 tests, 0 failures, 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): export the lp_* C ABI from liblogos_core.dll

The generated .def kept Itanium-mangled C++ only:

    if (name !~ /^_Z/) next

Every lp_* symbol is `extern "C"`, hence unmangled, so the filter dropped the
entire logos-protocol C ABI from the export table by construction. Measured on
the built PE before this change: 3050 exports, of which lp_* = 0 — not
exported, and not even defined in the image.

It stayed invisible because until B5 only C++ callers reached the shared
runtime. B5 re-emits every Qt-typed dependency wrapper as a VENEER over the lp
path, so a consumer that compiles such a wrapper into its own image now calls
lp_invoke / lp_client_create / lp_token_save directly. logos-basecamp compiles
package_manager_api.cpp into LogosBasecamp.exe, and the link failed with plain
`undefined reference to 'lp_invoke'` — no diagnostic pointing at the .def.

Exporting is the correct fix rather than letting the consumer link
liblogos_protocol.a itself: lp_token_save and friends operate on the
TokenManager singleton, so a static copy in the exe would reinstate exactly the
split-brain token store this whole .def scheme exists to prevent. Module plugins
are separate processes and keep their own per-image copy by design.

The prefix is deliberately tight — `lp_` only, not "anything unmangled" — so the
toolchain bookkeeping the ^_Z test was there to exclude (qt_version_tag_*, which
every image legitimately defines) stays excluded.

Delta is fully attributed: 3050 -> 3080 exports, exactly the 30 lp_* symbols,
with TokenManager (45) and LogosAPI (123) unchanged. LogosBasecamp.exe then
links, and still defines 0 TokenManager::instance while importing it from
liblogos_core.dll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): track protocol and plugin-qt master

logos-protocol#59 and logos-plugin-qt#19 merged, so both rev pins are retired and
their rationales rewritten to name the PRs that closed the gaps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:56:37 -03:00

1379 lines
52 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <gtest/gtest.h>
#include "logos_core.h"
#include "qt_test_adapter.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <set>
#include <string>
#include <vector>
namespace fs = std::filesystem;
static void clearModuleState() {
logos_core_terminate_all();
logos_core_clear();
}
// RAII temporary directory (uses mkdtemp, cleaned up on destruction)
struct TmpDir {
fs::path path;
TmpDir() {
std::string tmpl = (fs::temp_directory_path() / "logos_test_XXXXXX").string();
char* buf = new char[tmpl.size() + 1];
memcpy(buf, tmpl.c_str(), tmpl.size() + 1);
if (!mkdtemp(buf)) {
delete[] buf;
throw std::runtime_error("mkdtemp failed");
}
path = buf;
delete[] buf;
}
~TmpDir() {
std::error_code ec;
fs::remove_all(path, ec);
}
bool isValid() const { return fs::is_directory(path); }
// Returns path.string().c_str()-compatible value as std::string
std::string str() const { return path.string(); }
};
static void createFakeModule(const fs::path& parentDir,
const std::string& moduleName,
const std::string& mainFile,
const std::string& type = "core",
const std::vector<std::string>& dependencies = {}) {
fs::path moduleDir = parentDir / moduleName;
fs::create_directories(moduleDir);
nlohmann::json manifest;
manifest["name"] = moduleName;
manifest["version"] = "1.0.0";
manifest["type"] = type;
manifest["main"] = mainFile;
manifest["description"] = "Fake test module";
if (!dependencies.empty())
manifest["dependencies"] = dependencies;
std::ofstream mf(moduleDir / "manifest.json");
mf << manifest.dump();
mf.close();
std::ofstream bf(moduleDir / mainFile);
bf << "fake";
bf.close();
}
// Helpers to free null-terminated char** arrays returned by the C API.
static void freeStringArray(char** arr) {
if (!arr) return;
for (int i = 0; arr[i] != nullptr; ++i)
delete[] arr[i];
delete[] arr;
}
static int stringArrayLen(char** arr) {
if (!arr) return 0;
int n = 0;
while (arr[n]) ++n;
return n;
}
static std::set<std::string> stringArrayToSet(char** arr) {
std::set<std::string> s;
if (!arr) return s;
for (int i = 0; arr[i]; ++i)
s.insert(arr[i]);
return s;
}
class ModuleManagerTest : public ::testing::Test {
protected:
void SetUp() override {
clearModuleState();
}
void TearDown() override {
clearModuleState();
}
};
// =============================================================================
// Module Query Functions Tests
// =============================================================================
TEST_F(ModuleManagerTest, GetLoadedModules_ReturnsEmptyList) {
char** result = logos_core_get_loaded_modules();
ASSERT_NE(result, nullptr);
EXPECT_EQ(result[0], nullptr);
delete[] result;
}
TEST_F(ModuleManagerTest, GetKnownModules_ReturnsEmptyHash) {
char** result = logos_core_get_known_modules();
ASSERT_NE(result, nullptr);
EXPECT_EQ(result[0], nullptr);
delete[] result;
}
TEST_F(ModuleManagerTest, GetKnownModules_ReturnsCorrectHash) {
logos_core_register_module("module1", "/path/to/module1.dylib");
logos_core_register_module("module2", "/path/to/module2.dylib");
char** result = logos_core_get_known_modules();
ASSERT_NE(result, nullptr);
ASSERT_EQ(stringArrayLen(result), 2);
auto moduleSet = stringArrayToSet(result);
EXPECT_TRUE(moduleSet.count("module1"));
EXPECT_TRUE(moduleSet.count("module2"));
char* path1 = logos_core_get_module_path("module1");
char* path2 = logos_core_get_module_path("module2");
ASSERT_NE(path1, nullptr);
ASSERT_NE(path2, nullptr);
EXPECT_EQ(std::string(path1), "/path/to/module1.dylib");
EXPECT_EQ(std::string(path2), "/path/to/module2.dylib");
delete[] path1;
delete[] path2;
freeStringArray(result);
}
// logos_core_get_modules_info returns one rich JSON entry per known module:
// name, path, loaded flag, direct dependencies, direct dependents, and the
// embedded metadata. (Registered fake modules have no plugin file, so their
// metadata is null — the real-plugin metadata is covered separately.)
TEST_F(ModuleManagerTest, GetModulesInfo_ReturnsRichEntryPerModule) {
logos_core_register_module("module_a", "/path/to/module_a.dylib");
logos_core_register_module("module_b", "/path/to/module_b.dylib");
const char* depsA[] = {"module_b"};
logos_core_register_module_dependencies("module_a", depsA, 1);
logos_core_mark_module_loaded("module_b");
char* json = logos_core_get_modules_info();
ASSERT_NE(json, nullptr);
nlohmann::json info = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
free(json);
ASSERT_TRUE(info.is_array());
ASSERT_EQ(info.size(), 2u);
auto find = [&](const std::string& n) -> nlohmann::json {
for (const auto& e : info)
if (e.value("name", std::string{}) == n) return e;
return nlohmann::json();
};
nlohmann::json a = find("module_a");
ASSERT_FALSE(a.is_null());
EXPECT_EQ(a.value("path", std::string{}), "/path/to/module_a.dylib");
EXPECT_FALSE(a.value("loaded", true));
// Not loaded ⇒ loaded_at is 0.
EXPECT_EQ(a.value("loaded_at", int64_t{-1}), 0);
ASSERT_TRUE(a["dependencies"].is_array());
ASSERT_EQ(a["dependencies"].size(), 1u);
EXPECT_EQ(a["dependencies"][0].get<std::string>(), "module_b");
EXPECT_TRUE(a["dependents"].is_array());
EXPECT_TRUE(a["dependents"].empty());
// metadata key is always present; null for a registered (un-processed) module.
ASSERT_TRUE(a.contains("metadata"));
EXPECT_TRUE(a["metadata"].is_null());
nlohmann::json b = find("module_b");
ASSERT_FALSE(b.is_null());
EXPECT_TRUE(b.value("loaded", false));
// Loaded ⇒ loaded_at is a real timestamp (stamped at markLoaded).
EXPECT_GT(b.value("loaded_at", int64_t{0}), 0);
// module_a depends on module_b ⇒ module_b lists module_a as a dependent.
ASSERT_TRUE(b["dependents"].is_array());
ASSERT_EQ(b["dependents"].size(), 1u);
EXPECT_EQ(b["dependents"][0].get<std::string>(), "module_a");
}
TEST_F(ModuleManagerTest, GetModulesInfo_EmptyWhenNoModules) {
char* json = logos_core_get_modules_info();
ASSERT_NE(json, nullptr);
nlohmann::json info = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
free(json);
ASSERT_TRUE(info.is_array());
EXPECT_TRUE(info.empty());
}
TEST_F(ModuleManagerTest, IsModuleLoaded_ReturnsFalseForUnloaded) {
EXPECT_EQ(logos_core_is_module_loaded("nonexistent_module"), 0);
}
TEST_F(ModuleManagerTest, IsModuleKnown_ReturnsFalseForUnknown) {
EXPECT_EQ(logos_core_is_module_known("nonexistent_module"), 0);
}
TEST_F(ModuleManagerTest, IsModuleKnown_ReturnsTrueForKnown) {
logos_core_register_module("test_module", "/path/to/module");
EXPECT_EQ(logos_core_is_module_known("test_module"), 1);
}
// =============================================================================
// C String Array Functions Tests
// =============================================================================
TEST_F(ModuleManagerTest, GetLoadedModulesCStr_ReturnsNullTerminatedArrayWhenEmpty) {
char** result = logos_core_get_loaded_modules();
ASSERT_NE(result, nullptr);
EXPECT_EQ(result[0], nullptr);
delete[] result;
}
TEST_F(ModuleManagerTest, GetKnownModulesCStr_ReturnsNullTerminatedArrayWhenEmpty) {
char** result = logos_core_get_known_modules();
ASSERT_NE(result, nullptr);
EXPECT_EQ(result[0], nullptr);
delete[] result;
}
TEST_F(ModuleManagerTest, GetKnownModulesCStr_ReturnsCorrectArray) {
logos_core_register_module("module1", "/path/to/module1");
logos_core_register_module("module2", "/path/to/module2");
char** result = logos_core_get_known_modules();
ASSERT_NE(result, nullptr);
ASSERT_NE(result[0], nullptr);
ASSERT_NE(result[1], nullptr);
EXPECT_EQ(result[2], nullptr);
auto modules = stringArrayToSet(result);
EXPECT_TRUE(modules.count("module1"));
EXPECT_TRUE(modules.count("module2"));
freeStringArray(result);
}
// =============================================================================
// loadModule Error Cases Tests
// =============================================================================
TEST_F(ModuleManagerTest, LoadModule_ReturnsFalseForUnknownModule) {
int result = logos_core_load_module("nonexistent_module", false);
EXPECT_EQ(result, 0);
}
// =============================================================================
// unloadModule Error Cases Tests
// =============================================================================
TEST_F(ModuleManagerTest, UnloadModule_ReturnsFalseForNotLoaded) {
int result = logos_core_unload_module("nonexistent_module", false);
EXPECT_EQ(result, 0);
}
// =============================================================================
// resolveDependencies Function Tests
// =============================================================================
TEST_F(ModuleManagerTest, ResolveDependencies_ReturnsEmptyForEmptyInput) {
char** result = logos_core_resolve_dependencies(nullptr, 0);
ASSERT_NE(result, nullptr);
EXPECT_EQ(result[0], nullptr);
delete[] result;
}
TEST_F(ModuleManagerTest, ResolveDependencies_ReturnsEmptyForUnknownModule) {
const char* names[] = {"unknown_module"};
char** result = logos_core_resolve_dependencies(names, 1);
ASSERT_NE(result, nullptr);
EXPECT_EQ(result[0], nullptr);
delete[] result;
}
TEST_F(ModuleManagerTest, ResolveDependencies_ReturnsSingleModuleWithNoDeps) {
logos_core_register_module("module_a", "/path/to/module_a");
logos_core_register_module_dependencies("module_a", nullptr, 0);
const char* names[] = {"module_a"};
char** result = logos_core_resolve_dependencies(names, 1);
ASSERT_EQ(stringArrayLen(result), 1);
EXPECT_EQ(std::string(result[0]), "module_a");
freeStringArray(result);
}
TEST_F(ModuleManagerTest, ResolveDependencies_ReturnsCorrectOrder) {
logos_core_register_module("module_a", "/path/to/module_a");
logos_core_register_module("module_b", "/path/to/module_b");
const char* depsA[] = {"module_b"};
logos_core_register_module_dependencies("module_a", depsA, 1);
logos_core_register_module_dependencies("module_b", nullptr, 0);
const char* names[] = {"module_a"};
char** result = logos_core_resolve_dependencies(names, 1);
ASSERT_EQ(stringArrayLen(result), 2);
EXPECT_EQ(std::string(result[0]), "module_b");
EXPECT_EQ(std::string(result[1]), "module_a");
freeStringArray(result);
}
TEST_F(ModuleManagerTest, ResolveDependencies_HandlesTransitiveDeps) {
logos_core_register_module("module_a", "/path/to/module_a");
logos_core_register_module("module_b", "/path/to/module_b");
logos_core_register_module("module_c", "/path/to/module_c");
const char* depsA[] = {"module_b"};
const char* depsB[] = {"module_c"};
logos_core_register_module_dependencies("module_a", depsA, 1);
logos_core_register_module_dependencies("module_b", depsB, 1);
logos_core_register_module_dependencies("module_c", nullptr, 0);
const char* names[] = {"module_a"};
char** result = logos_core_resolve_dependencies(names, 1);
ASSERT_EQ(stringArrayLen(result), 3);
EXPECT_EQ(std::string(result[0]), "module_c");
EXPECT_EQ(std::string(result[1]), "module_b");
EXPECT_EQ(std::string(result[2]), "module_a");
freeStringArray(result);
}
// =============================================================================
// C API: logos_core_load_module with_dependencies=true Tests
// =============================================================================
TEST_F(ModuleManagerTest, LoadModuleWithDeps_AbortsForNull) {
EXPECT_DEATH(logos_core_load_module(nullptr, true), "");
}
TEST_F(ModuleManagerTest, LoadModuleWithDeps_ReturnsZeroForUnknown) {
int result = logos_core_load_module("unknown_module", true);
EXPECT_EQ(result, 0);
}
// =============================================================================
// Idempotent-load contract: "already loaded ⇒ success"
//
// logos_core_load_module is an "ensure loaded" guard, not a "load fresh"
// command. Pinning this in tests so the contract documented in
// logos_core.h doesn't quietly regress — basecamp's PluginLoader and
// logoscore-cli's load-module both rely on calling it against modules
// the loader may have already brought up at startup, and we don't want
// a future refactor to start returning 0 in that case (which previously
// caused UI-plugin loads to abort when a core dep was pre-loaded).
//
// We exercise this without a loader: register fake modules, mark them
// loaded via the registry adapter, then call the C entry point. The
// short-circuit at the top of ModuleManager::loadModuleInternal never
// reaches the descriptor / loader path, so no subprocess is spawned.
// =============================================================================
TEST_F(ModuleManagerTest, LoadModule_ReturnsTrueWhenAlreadyLoaded) {
logos_core_register_module("preloaded", "/fake/path");
logos_core_mark_module_loaded("preloaded");
ASSERT_EQ(logos_core_is_module_loaded("preloaded"), 1);
// First call: module is already loaded ⇒ no-op success.
EXPECT_EQ(logos_core_load_module("preloaded", false), 1)
<< "loading an already-loaded module must return 1 (no-op success)";
// Repeating the call must stay idempotent — still success, still loaded.
EXPECT_EQ(logos_core_load_module("preloaded", false), 1);
EXPECT_EQ(logos_core_is_module_loaded("preloaded"), 1);
}
TEST_F(ModuleManagerTest, LoadModuleWithDeps_ReturnsTrueWhenAllAlreadyLoaded) {
// Build a tiny dep graph: parent → child. Both pre-marked loaded.
logos_core_register_module("parent", "/fake/parent");
logos_core_register_module("child", "/fake/child");
const char* deps[] = {"child"};
logos_core_register_module_dependencies("parent", deps, 1);
logos_core_mark_module_loaded("child");
logos_core_mark_module_loaded("parent");
// with_dependencies=true walks the resolved order and calls
// loadModuleInternal for each; every step short-circuits on
// isLoaded() and returns true, so the overall call returns 1.
EXPECT_EQ(logos_core_load_module("parent", true), 1)
<< "with_dependencies=true must return 1 when the target and "
"all of its deps were already loaded before the call";
EXPECT_EQ(logos_core_is_module_loaded("parent"), 1);
EXPECT_EQ(logos_core_is_module_loaded("child"), 1);
}
// =============================================================================
// Dependency resolution failure: logos_core_load_module(name, true) must
// return 0 when the dependency graph cannot be fully resolved.
//
// The resolver silently drops unknown modules and detects cycles. Before
// this fix, loadModuleWithDependencies only checked whether the *target*
// appeared in the (possibly partial) resolved order — it didn't verify
// the resolution was clean. A module whose transitive dependency was
// unknown would load successfully, violating the contract in logos_core.h
// ("returns 0 when dependency resolution fails").
// =============================================================================
TEST_F(ModuleManagerTest, LoadModuleWithDeps_FailsWhenDirectDependencyUnknown) {
logos_core_register_module("parent", "/fake/parent");
const char* deps[] = {"unknown_dep"};
logos_core_register_module_dependencies("parent", deps, 1);
// "unknown_dep" is not registered → resolution has missing deps → fail.
EXPECT_EQ(logos_core_load_module("parent", true), 0)
<< "must return 0 when a direct dependency is unknown";
}
TEST_F(ModuleManagerTest, LoadModuleWithDeps_FailsWhenTransitiveDependencyUnknown) {
logos_core_register_module("top", "/fake/top");
logos_core_register_module("mid", "/fake/mid");
const char* depsTop[] = {"mid"};
const char* depsMid[] = {"bottom_unknown"};
logos_core_register_module_dependencies("top", depsTop, 1);
logos_core_register_module_dependencies("mid", depsMid, 1);
// "bottom_unknown" not registered → transitive resolution fails.
EXPECT_EQ(logos_core_load_module("top", true), 0)
<< "must return 0 when a transitive dependency is unknown";
}
TEST_F(ModuleManagerTest, LoadModuleWithDeps_FailsOnCircularDependency) {
logos_core_register_module("cyc_a", "/fake/cyc_a");
logos_core_register_module("cyc_b", "/fake/cyc_b");
const char* depsA[] = {"cyc_b"};
const char* depsB[] = {"cyc_a"};
logos_core_register_module_dependencies("cyc_a", depsA, 1);
logos_core_register_module_dependencies("cyc_b", depsB, 1);
// Cycle detected → must return 0.
EXPECT_EQ(logos_core_load_module("cyc_a", true), 0)
<< "must return 0 when a circular dependency is detected";
}
// =============================================================================
// Module Directory Management Tests
// =============================================================================
TEST_F(ModuleManagerTest, AddModulesDir_SetsFirstDirectory) {
logos_core_add_modules_dir("/tmp/test_modules");
ASSERT_EQ(logos_core_get_modules_dirs_count(), 1);
char* dir = logos_core_get_modules_dir_at(0);
ASSERT_NE(dir, nullptr);
EXPECT_EQ(std::string(dir), "/tmp/test_modules");
delete[] dir;
}
TEST_F(ModuleManagerTest, AddModulesDir_AppendsDirectory) {
logos_core_add_modules_dir("/tmp/dir1");
logos_core_add_modules_dir("/tmp/dir2");
logos_core_add_modules_dir("/tmp/dir3");
ASSERT_EQ(logos_core_get_modules_dirs_count(), 3);
char* d0 = logos_core_get_modules_dir_at(0);
char* d1 = logos_core_get_modules_dir_at(1);
char* d2 = logos_core_get_modules_dir_at(2);
ASSERT_NE(d0, nullptr);
ASSERT_NE(d1, nullptr);
ASSERT_NE(d2, nullptr);
EXPECT_EQ(std::string(d0), "/tmp/dir1");
EXPECT_EQ(std::string(d1), "/tmp/dir2");
EXPECT_EQ(std::string(d2), "/tmp/dir3");
delete[] d0;
delete[] d1;
delete[] d2;
}
TEST_F(ModuleManagerTest, GetModulesDirs_ReturnsEmptyAfterClear) {
logos_core_add_modules_dir("/tmp/dir1");
clearModuleState();
EXPECT_EQ(logos_core_get_modules_dirs_count(), 0);
}
// =============================================================================
// Discovery Tests — fake installed modules
// =============================================================================
TEST_F(ModuleManagerTest, DiscoverInstalledModules_DoesNotCrashWithEmptyDir) {
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
TEST_F(ModuleManagerTest, DiscoverInstalledModules_DoesNotCrashWithNonexistentDir) {
logos_core_add_modules_dir("/tmp/nonexistent_dir_12345");
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
TEST_F(ModuleManagerTest, DiscoverInstalledModules_FindsFakeModulesWithoutCrash) {
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
createFakeModule(tmpDir.path, "fake_module_a", "fake_module_a_plugin.so");
createFakeModule(tmpDir.path, "fake_module_b", "fake_module_b_plugin.so");
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
TEST_F(ModuleManagerTest, DiscoverInstalledModules_IgnoresModulesWithoutManifest) {
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
fs::path moduleDir = tmpDir.path / "no_manifest_module";
fs::create_directories(moduleDir);
std::ofstream bf(moduleDir / "plugin.so");
bf << "fake";
bf.close();
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
TEST_F(ModuleManagerTest, DiscoverInstalledModules_IgnoresUiTypeModules) {
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
createFakeModule(tmpDir.path, "ui_module", "ui_module_plugin.so", "ui");
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
TEST_F(ModuleManagerTest, DiscoverInstalledModules_MultipleDirectories) {
TmpDir tmpDir1;
TmpDir tmpDir2;
ASSERT_TRUE(tmpDir1.isValid());
ASSERT_TRUE(tmpDir2.isValid());
createFakeModule(tmpDir1.path, "module_in_dir1", "module_in_dir1_plugin.so");
createFakeModule(tmpDir2.path, "module_in_dir2", "module_in_dir2_plugin.so");
logos_core_add_modules_dir(tmpDir1.str().c_str());
logos_core_add_modules_dir(tmpDir2.str().c_str());
ASSERT_EQ(logos_core_get_modules_dirs_count(), 2);
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
TEST_F(ModuleManagerTest, DiscoverInstalledModules_InvalidManifestJson) {
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
fs::path moduleDir = tmpDir.path / "bad_manifest_module";
fs::create_directories(moduleDir);
std::ofstream mf(moduleDir / "manifest.json");
mf << "{ this is not valid json }}}";
mf.close();
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr);
delete[] known;
}
// =============================================================================
// Loaded-flag preservation across re-registration
// =============================================================================
TEST_F(ModuleManagerTest, RegisterModule_PreservesLoadedFlagOnReregister) {
logos_core_register_module("test_module", "/path/v1");
logos_core_mark_module_loaded("test_module");
ASSERT_EQ(logos_core_is_module_loaded("test_module"), 1);
logos_core_register_module("test_module", "/path/v2");
EXPECT_EQ(logos_core_is_module_loaded("test_module"), 1)
<< "Re-registering a known module must preserve its loaded flag";
char* path = logos_core_get_module_path("test_module");
ASSERT_NE(path, nullptr);
EXPECT_EQ(std::string(path), "/path/v2");
delete[] path;
}
TEST_F(ModuleManagerTest, RegisterDependencies_PreservesLoadedFlag) {
logos_core_register_module("test_module", "/path/to/module");
logos_core_mark_module_loaded("test_module");
ASSERT_EQ(logos_core_is_module_loaded("test_module"), 1);
const char* deps[] = {"dep_a", "dep_b"};
logos_core_register_module_dependencies("test_module", deps, 2);
EXPECT_EQ(logos_core_is_module_loaded("test_module"), 1)
<< "Updating dependencies must not wipe the loaded flag";
EXPECT_EQ(logos_core_get_module_dependencies_count("test_module"), 2);
}
// =============================================================================
// End-to-end regression tests using a real Qt module.
// =============================================================================
class RealModuleRegistryTest : public ::testing::Test {
protected:
std::string modulePath;
void SetUp() override {
clearModuleState();
const char* envPlugin = std::getenv("TEST_PLUGIN");
if (envPlugin && std::strlen(envPlugin) > 0 &&
fs::exists(envPlugin)) {
modulePath = envPlugin;
return;
}
GTEST_SKIP() << "No real test module available. "
<< "Set TEST_PLUGIN env var to a built Qt plugin (.so/.dylib).";
}
void TearDown() override {
clearModuleState();
}
};
TEST_F(RealModuleRegistryTest, ProcessModule_RegistersRealModule) {
char* name = logos_core_process_module(modulePath.c_str());
ASSERT_NE(name, nullptr) << "process_module failed for " << modulePath;
EXPECT_NE(std::string(name), "");
EXPECT_EQ(logos_core_is_module_known(name), 1);
EXPECT_EQ(logos_core_is_module_loaded(name), 0);
delete[] name;
}
// For a real plugin, get_modules_info must carry the embedded metadata parsed
// straight from the binary (via ModuleLib::LogosModule) — name + version.
TEST_F(RealModuleRegistryTest, GetModulesInfo_PopulatesEmbeddedMetadata) {
char* name = logos_core_process_module(modulePath.c_str());
ASSERT_NE(name, nullptr) << "process_module failed for " << modulePath;
std::string moduleName(name);
delete[] name;
char* json = logos_core_get_modules_info();
ASSERT_NE(json, nullptr);
nlohmann::json info = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
free(json);
ASSERT_TRUE(info.is_array());
nlohmann::json entry;
for (const auto& e : info)
if (e.value("name", std::string{}) == moduleName) { entry = e; break; }
ASSERT_FALSE(entry.is_null()) << "processed module absent from modules-info";
EXPECT_FALSE(entry.value("path", std::string{}).empty());
ASSERT_TRUE(entry["metadata"].is_object())
<< "real plugin must yield a non-null metadata object";
EXPECT_EQ(entry["metadata"].value("name", std::string{}), moduleName);
EXPECT_FALSE(entry["metadata"].value("version", std::string{}).empty())
<< "built test modules declare a version in metadata.json";
}
// =============================================================================
// Security regression: privileged-name impersonation during discovery (F-022).
//
// Module identity used to be taken from the name embedded in the plugin's own
// Qt metadata, ignoring the trusted package name the package manager scanned.
// That let a package installed under an innocuous name ship a binary whose
// embedded metadata claims a privileged name (e.g. "capability_module"), and
// the registry would key the module under that privileged name — wiring the
// attacker's plugin into the impersonated module's token/trust relationships.
//
// The discovery path (logos_core_refresh_modules → discoverInstalledModules)
// must bind identity to the *trusted package name* (InstalledPackage::name)
// and refuse a plugin whose embedded name disagrees.
//
// These tests use the real TEST_PLUGIN as the impersonating payload: we first
// read its real embedded name via the raw process-module path, then plant a
// package whose manifest name differs from it, and assert the embedded name
// never leaks into the registry.
// =============================================================================
class ImpersonationRegistryTest : public ::testing::Test {
protected:
std::string modulePath; // real TEST_PLUGIN on disk
std::string embeddedName; // the name baked into TEST_PLUGIN's metadata
void SetUp() override {
clearModuleState();
const char* envPlugin = std::getenv("TEST_PLUGIN");
if (!envPlugin || std::strlen(envPlugin) == 0 || !fs::exists(envPlugin)) {
GTEST_SKIP() << "No real test module available. "
<< "Set TEST_PLUGIN env var to a built Qt plugin (.so/.dylib).";
}
modulePath = envPlugin;
// Discover the plugin's self-asserted embedded name via the raw
// process-module path (which intentionally trusts the embedded name).
// This is the name an attacker's binary would carry to impersonate.
char* name = logos_core_process_module(modulePath.c_str());
ASSERT_NE(name, nullptr) << "process_module failed for " << modulePath;
embeddedName = name;
delete[] name;
ASSERT_FALSE(embeddedName.empty());
// Wipe the scratch registration + modules dirs so each test below
// starts from a clean registry.
clearModuleState();
}
void TearDown() override {
clearModuleState();
}
// Plant a package directory named `packageName` whose manifest declares
// name=packageName but whose main binary is a byte copy of the real
// TEST_PLUGIN (embedding `embeddedName`).
void plantPackage(const fs::path& parentDir, const std::string& packageName) {
const std::string mainFile = packageName + "_plugin.so";
createFakeModule(parentDir, packageName, mainFile); // manifest + placeholder
std::error_code ec;
fs::copy_file(modulePath, parentDir / packageName / mainFile,
fs::copy_options::overwrite_existing, ec);
ASSERT_FALSE(ec) << "failed to copy real plugin into package dir: " << ec.message();
}
};
// The core repro: an "innocent_helper" package carrying a binary that claims
// the privileged embedded name must NOT register under that privileged name,
// and must not silently bind it either. Before the fix the registry keyed the
// module under `embeddedName`, so is_module_known(embeddedName) was 1.
TEST_F(ImpersonationRegistryTest, Discovery_RefusesPrivilegedNameImpersonation) {
// Only meaningful when the trusted package name differs from the embedded
// one (true for the capability_module fixture: package "innocent_helper"
// vs embedded "capability_module").
const std::string packageName = "innocent_helper";
ASSERT_NE(packageName, embeddedName);
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
plantPackage(tmpDir.path, packageName);
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
// The impersonated privileged identity must never enter the registry.
EXPECT_EQ(logos_core_is_module_known(embeddedName.c_str()), 0)
<< "a package must not be able to claim the embedded name '"
<< embeddedName << "' it does not legitimately own";
// And the lying package is refused outright (its binary's identity does
// not match its package name), so the innocuous name isn't bound either.
EXPECT_EQ(logos_core_is_module_known(packageName.c_str()), 0)
<< "a package whose binary impersonates another module must be refused";
char** known = logos_core_get_known_modules();
ASSERT_NE(known, nullptr);
EXPECT_EQ(known[0], nullptr) << "no module should be registered from a lying package";
freeStringArray(known);
}
// Positive control: an honest package whose manifest name matches the binary's
// embedded name still registers normally. The fix must not break legitimate
// discovery of (even reserved-named) modules installed under their true name.
TEST_F(ImpersonationRegistryTest, Discovery_HonestPackageRegistersUnderItsName) {
TmpDir tmpDir;
ASSERT_TRUE(tmpDir.isValid());
plantPackage(tmpDir.path, embeddedName); // manifest name == embedded name
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
EXPECT_EQ(logos_core_is_module_known(embeddedName.c_str()), 1)
<< "an honest package (manifest name == embedded name) must register";
char* path = logos_core_get_module_path(embeddedName.c_str());
ASSERT_NE(path, nullptr);
EXPECT_NE(std::string(path), "");
delete[] path;
}
// =============================================================================
// Cascading unload: logos_core_unload_module(name, true)
//
// The cascade is exercised without real Qt modules. We:
// 1. Set up fake manifests on disk (PackageManagerLib scan sees the
// dependency edges).
// 2. Register the same modules directly in ModuleRegistry so it believes
// they exist (the fake .so files are not loadable Qt plugins, so
// refresh_modules alone wouldn't populate the registry).
// 3. Register placeholder "processes" + mark loaded so hasProcess() returns
// true — `terminateProcess` on a placeholder is a no-op but still
// removes the entry cleanly.
// =============================================================================
class CascadeUnloadTest : public ::testing::Test {
protected:
TmpDir tmpDir;
void SetUp() override {
clearModuleState();
logos_core_clear_processes();
}
void TearDown() override {
clearModuleState();
logos_core_clear_processes();
}
// Registers a module in both ModuleRegistry and as a loaded fake process.
void setupLoaded(const std::string& name,
const std::vector<std::string>& deps = {}) {
std::string path = (tmpDir.path / name / (name + "_plugin.so")).string();
logos_core_register_module(name.c_str(), path.c_str());
std::vector<const char*> depPtrs;
depPtrs.reserve(deps.size());
for (const auto& d : deps) depPtrs.push_back(d.c_str());
logos_core_register_module_dependencies(
name.c_str(),
depPtrs.empty() ? nullptr : depPtrs.data(),
static_cast<int>(depPtrs.size()));
logos_core_register_process(name.c_str());
logos_core_mark_module_loaded(name.c_str());
}
void writeManifestsAndScan(
const std::vector<std::tuple<std::string, std::vector<std::string>>>& modules)
{
for (const auto& [name, deps] : modules) {
createFakeModule(tmpDir.path, name, name + "_plugin.so", "core", deps);
}
logos_core_add_modules_dir(tmpDir.str().c_str());
logos_core_refresh_modules();
}
};
TEST_F(CascadeUnloadTest, UnloadWithDependents_ReturnsZeroWhenTargetNotLoaded) {
// Module is known but not loaded.
logos_core_register_module("foo", "/foo");
int result = logos_core_unload_module("foo", true);
EXPECT_EQ(result, 0);
}
TEST_F(CascadeUnloadTest, UnloadWithDependents_NoDependents_UnloadsTargetOnly) {
// Single loaded module with no dependents on disk → cascade is just the
// target.
writeManifestsAndScan({ {"solo", {}} });
setupLoaded("solo");
ASSERT_EQ(logos_core_is_module_loaded("solo"), 1);
int result = logos_core_unload_module("solo", true);
EXPECT_EQ(result, 1);
EXPECT_EQ(logos_core_is_module_loaded("solo"), 0);
EXPECT_EQ(logos_core_has_process("solo"), 0);
}
TEST_F(CascadeUnloadTest, UnloadWithDependents_RecursiveDependentsLeavesFirst) {
// Graph: a -> b -> c (a depends on b, b depends on c).
// Unloading c should also bring down b and a, in the order a, b, c.
writeManifestsAndScan({
{"c", {}},
{"b", {"c"}},
{"a", {"b"}},
});
setupLoaded("c", {});
setupLoaded("b", {"c"});
setupLoaded("a", {"b"});
ASSERT_EQ(logos_core_is_module_loaded("a"), 1);
ASSERT_EQ(logos_core_is_module_loaded("b"), 1);
ASSERT_EQ(logos_core_is_module_loaded("c"), 1);
int result = logos_core_unload_module("c", true);
EXPECT_EQ(result, 1);
EXPECT_EQ(logos_core_is_module_loaded("a"), 0);
EXPECT_EQ(logos_core_is_module_loaded("b"), 0);
EXPECT_EQ(logos_core_is_module_loaded("c"), 0);
EXPECT_EQ(logos_core_has_process("a"), 0);
EXPECT_EQ(logos_core_has_process("b"), 0);
EXPECT_EQ(logos_core_has_process("c"), 0);
}
TEST_F(CascadeUnloadTest, UnloadWithDependents_UnloadedDependentsIgnored) {
// b depends on c. Only c is loaded; b is known but not loaded. Cascade
// should only touch c. b stays unloaded (not "failed to unload").
writeManifestsAndScan({
{"c", {}},
{"b", {"c"}},
});
setupLoaded("c", {});
// Register b in registry but don't mark it loaded.
logos_core_register_module("b", "/b");
const char* depsB[] = {"c"};
logos_core_register_module_dependencies("b", depsB, 1);
int result = logos_core_unload_module("c", true);
EXPECT_EQ(result, 1);
EXPECT_EQ(logos_core_is_module_loaded("c"), 0);
EXPECT_EQ(logos_core_is_module_loaded("b"), 0);
}
TEST_F(CascadeUnloadTest, UnloadWithDependents_DiamondDependents) {
// Diamond: a -> b -> d ; a -> c -> d. Unloading d should bring down
// a, b, c in some valid order (a before b and c; b and c before d).
writeManifestsAndScan({
{"d", {}},
{"b", {"d"}},
{"c", {"d"}},
{"a", {"b", "c"}},
});
setupLoaded("d", {});
setupLoaded("b", {"d"});
setupLoaded("c", {"d"});
setupLoaded("a", {"b", "c"});
int result = logos_core_unload_module("d", true);
EXPECT_EQ(result, 1);
EXPECT_EQ(logos_core_is_module_loaded("a"), 0);
EXPECT_EQ(logos_core_is_module_loaded("b"), 0);
EXPECT_EQ(logos_core_is_module_loaded("c"), 0);
EXPECT_EQ(logos_core_is_module_loaded("d"), 0);
}
TEST_F(CascadeUnloadTest, UnloadWithDependents_AbortsForNull) {
EXPECT_DEATH(logos_core_unload_module(nullptr, true), "");
}
TEST_F(RealModuleRegistryTest, ProcessModule_PreservesLoadedFlagOnReprocess) {
char* name1 = logos_core_process_module(modulePath.c_str());
ASSERT_NE(name1, nullptr) << "process_module failed for " << modulePath;
std::string modName = name1;
delete[] name1;
ASSERT_EQ(logos_core_is_module_known(modName.c_str()), 1);
logos_core_mark_module_loaded(modName.c_str());
ASSERT_EQ(logos_core_is_module_loaded(modName.c_str()), 1);
char* name2 = logos_core_process_module(modulePath.c_str());
ASSERT_NE(name2, nullptr);
EXPECT_EQ(std::string(name2), modName);
delete[] name2;
EXPECT_EQ(logos_core_is_module_loaded(modName.c_str()), 1)
<< "Re-processing a loaded module must preserve its loaded flag";
// Verify it still appears in the loaded list
char** loaded = logos_core_get_loaded_modules();
auto loadedSet = stringArrayToSet(loaded);
freeStringArray(loaded);
EXPECT_TRUE(loadedSet.count(modName))
<< "get_loaded_modules() must still report the module as loaded";
}
// =============================================================================
// Dependency graph queries:
// logos_core_get_module_dependencies(name, recursive)
// logos_core_get_module_dependents(name, recursive)
//
// These read from the in-process registry. We populate it with
// logos_core_register_module + logos_core_register_module_dependencies
// (which in turn trigger recomputeDependentsLocked), then check both the
// direct and recursive traversals against known-shaped graphs.
// =============================================================================
class DependencyQueryTest : public ::testing::Test {
protected:
void SetUp() override {
clearModuleState();
}
void TearDown() override {
clearModuleState();
}
// Register a module with a (possibly empty) direct dependency list. Path
// value isn't exercised by the queries — anything non-empty is fine.
void reg(const std::string& name,
const std::vector<std::string>& deps = {}) {
logos_core_register_module(name.c_str(), ("/" + name).c_str());
std::vector<const char*> depPtrs;
depPtrs.reserve(deps.size());
for (const auto& d : deps) depPtrs.push_back(d.c_str());
logos_core_register_module_dependencies(
name.c_str(),
depPtrs.empty() ? nullptr : depPtrs.data(),
static_cast<int>(depPtrs.size()));
}
};
TEST_F(DependencyQueryTest, GetModuleDependencies_UnknownName_ReturnsEmpty) {
char** deps = logos_core_get_module_dependencies("ghost", false);
EXPECT_EQ(stringArrayLen(deps), 0);
freeStringArray(deps);
deps = logos_core_get_module_dependencies("ghost", true);
EXPECT_EQ(stringArrayLen(deps), 0);
freeStringArray(deps);
}
TEST_F(DependencyQueryTest, GetModuleDependents_UnknownName_ReturnsEmpty) {
char** d = logos_core_get_module_dependents("ghost", false);
EXPECT_EQ(stringArrayLen(d), 0);
freeStringArray(d);
d = logos_core_get_module_dependents("ghost", true);
EXPECT_EQ(stringArrayLen(d), 0);
freeStringArray(d);
}
TEST_F(DependencyQueryTest, GetModuleDependencies_NoDeps_ReturnsEmpty) {
reg("leaf");
char** deps = logos_core_get_module_dependencies("leaf", false);
EXPECT_EQ(stringArrayLen(deps), 0);
freeStringArray(deps);
deps = logos_core_get_module_dependencies("leaf", true);
EXPECT_EQ(stringArrayLen(deps), 0);
freeStringArray(deps);
}
TEST_F(DependencyQueryTest, GetModuleDependencies_DirectVsRecursive) {
// Chain: a -> b -> c. Direct deps of a = {b}. Recursive deps of a = {b, c}.
reg("c");
reg("b", {"c"});
reg("a", {"b"});
char** direct = logos_core_get_module_dependencies("a", false);
EXPECT_EQ(stringArrayToSet(direct), (std::set<std::string>{"b"}));
freeStringArray(direct);
char** recursive = logos_core_get_module_dependencies("a", true);
EXPECT_EQ(stringArrayToSet(recursive), (std::set<std::string>{"b", "c"}));
freeStringArray(recursive);
}
TEST_F(DependencyQueryTest, GetModuleDependents_DirectVsRecursive) {
// Chain: a -> b -> c. Direct dependents of c = {b}. Recursive = {b, a}.
reg("c");
reg("b", {"c"});
reg("a", {"b"});
char** direct = logos_core_get_module_dependents("c", false);
EXPECT_EQ(stringArrayToSet(direct), (std::set<std::string>{"b"}));
freeStringArray(direct);
char** recursive = logos_core_get_module_dependents("c", true);
EXPECT_EQ(stringArrayToSet(recursive), (std::set<std::string>{"b", "a"}));
freeStringArray(recursive);
}
TEST_F(DependencyQueryTest, GetModuleDependencies_Diamond_RecursiveDeduplicates) {
// Diamond: a -> b -> d ; a -> c -> d. Recursive deps of a must include
// {b, c, d} with no duplicate entries for d.
reg("d");
reg("b", {"d"});
reg("c", {"d"});
reg("a", {"b", "c"});
char** recursive = logos_core_get_module_dependencies("a", true);
std::set<std::string> got = stringArrayToSet(recursive);
int n = stringArrayLen(recursive);
freeStringArray(recursive);
EXPECT_EQ(got, (std::set<std::string>{"b", "c", "d"}));
// No duplicate d entries — set and array length must agree.
EXPECT_EQ(n, static_cast<int>(got.size()));
}
TEST_F(DependencyQueryTest, GetModuleDependents_Diamond_RecursiveDeduplicates) {
// Same diamond — d has {b, c} as direct dependents and {b, c, a}
// transitively. The BFS must not report a twice even though both
// b and c list it as a dependent.
reg("d");
reg("b", {"d"});
reg("c", {"d"});
reg("a", {"b", "c"});
char** direct = logos_core_get_module_dependents("d", false);
EXPECT_EQ(stringArrayToSet(direct), (std::set<std::string>{"b", "c"}));
freeStringArray(direct);
char** recursive = logos_core_get_module_dependents("d", true);
std::set<std::string> got = stringArrayToSet(recursive);
int n = stringArrayLen(recursive);
freeStringArray(recursive);
EXPECT_EQ(got, (std::set<std::string>{"a", "b", "c"}));
EXPECT_EQ(n, static_cast<int>(got.size()));
}
TEST_F(DependencyQueryTest, GetModuleDependencies_SelfNotIncluded) {
reg("leaf");
reg("root", {"leaf"});
char** recursive = logos_core_get_module_dependencies("root", true);
std::set<std::string> got = stringArrayToSet(recursive);
freeStringArray(recursive);
EXPECT_EQ(got.count("root"), 0u);
EXPECT_EQ(got, (std::set<std::string>{"leaf"}));
}
TEST_F(DependencyQueryTest, GetModuleDependencies_AbortsForNull) {
EXPECT_DEATH(logos_core_get_module_dependencies(nullptr, false), "");
}
TEST_F(DependencyQueryTest, GetModuleDependents_AbortsForNull) {
EXPECT_DEATH(logos_core_get_module_dependents(nullptr, false), "");
}
// =============================================================================
// Derived access-restriction computation (graph + policy -> allowed callers)
// =============================================================================
//
// computeDerivedAllowedCallers() is the registry-backed counterpart of the
// pure derivation seam: it reads the live dependency graph + loaded set + the
// access policy and returns what core would register with capability_module for
// a target — without any RPC. We drive it with the test registry adapters
// (register_module / register_module_dependencies / mark_module_loaded) and the
// ModuleManager::setAccessPolicy entry point.
class DerivedRestrictionsManagerTest : public ::testing::Test {
protected:
void SetUp() override { clearModuleState(); }
void TearDown() override {
// Clear the policy so it doesn't leak into other suites.
ModuleManager::setAccessPolicy("");
clearModuleState();
}
// Register `name` with `deps` declared as dependencies.
static void reg(const std::string& name, const std::vector<std::string>& deps) {
logos_core_register_module(name.c_str(), ("/fake/" + name).c_str());
std::vector<const char*> d;
for (const auto& s : deps) d.push_back(s.c_str());
logos_core_register_module_dependencies(name.c_str(), d.data(),
static_cast<int>(d.size()));
}
static std::set<std::string> derived(const std::string& target) {
auto v = ModuleManager::computeDerivedAllowedCallers(target);
return std::set<std::string>(v.begin(), v.end());
}
// Minimal enforce policy with no explicit restrictions — turns derivation on.
static const char* enforceEnvelope() {
return "{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{}}";
}
};
TEST_F(DerivedRestrictionsManagerTest, LoadedDependentPlusTrusted) {
// a depends on b; both loaded. b's allowed callers = {a} trusted.
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_EQ(derived("b"),
(std::set<std::string>{"a", "core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, UnloadedDependentExcluded) {
// a declares b but is NOT loaded — a must not appear in b's callers.
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b"); // a left unloaded
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_EQ(derived("b"), (std::set<std::string>{"core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, ZeroDependentsIsTrustedOnly) {
reg("solo", {});
logos_core_mark_module_loaded("solo");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_EQ(derived("solo"), (std::set<std::string>{"core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, NoEnforcePolicyDerivesNothing) {
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
// No policy set at all -> derivation off -> empty.
EXPECT_TRUE(derived("b").empty());
// A non-enforce policy is also inert.
ModuleManager::setAccessPolicy(
"{\"version\":1,\"mode\":\"audit\",\"restrictions\":{}}");
EXPECT_TRUE(derived("b").empty());
}
TEST_F(DerivedRestrictionsManagerTest, ExplicitPolicyOverridesDerived) {
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
// Explicit entry for b names only "x" — replaces the derived {a, trusted}.
ModuleManager::setAccessPolicy(
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{"
"\"b\":{\"allowedCallers\":[\"x\"]}}}");
EXPECT_EQ(derived("b"), (std::set<std::string>{"x"}));
}
TEST_F(DerivedRestrictionsManagerTest, ExemptTargetsNeverDerived) {
reg("capability_module", {});
logos_core_mark_module_loaded("capability_module");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_TRUE(derived("capability_module").empty());
}
TEST_F(DerivedRestrictionsManagerTest, UnloadDropsDependentFromCallers) {
reg("b", {});
reg("a", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("a");
ModuleManager::setAccessPolicy(enforceEnvelope());
EXPECT_TRUE(derived("b").count("a"));
// Unloading a (it stays known, dependency edge remains) drops it.
ModuleManager::registry().markUnloaded("a");
EXPECT_FALSE(derived("b").count("a"));
EXPECT_EQ(derived("b"), (std::set<std::string>{"core", "core_service"}));
}
TEST_F(DerivedRestrictionsManagerTest, TrustedDependentNotDuplicated) {
// A loaded dependent that shares a trusted name must appear exactly once in
// the registered list (the set-based `derived()` helper would hide a dup, so
// inspect the raw vector here).
reg("b", {});
reg("core", {"b"});
logos_core_mark_module_loaded("b");
logos_core_mark_module_loaded("core");
ModuleManager::setAccessPolicy(enforceEnvelope());
auto callers = ModuleManager::computeDerivedAllowedCallers("b");
EXPECT_EQ(std::count(callers.begin(), callers.end(), std::string("core")), 1);
}
// ── The deny-by-default switch, both directions ─────────────────────────────
//
// These two are the contract for the operator-facing flag (`mode: "enforce"`,
// reached as `logoscore --access-policy enforce` / `LogosBasecamp
// --access-policy enforce`). They share one scenario deliberately: the SAME
// undeclared pair must be allowed with the flag off and denied with it on, and
// the declared pair must survive the flip. A change that denied everything
// would pass the "denied" half on its own, so the declared-caller assertion is
// the one carrying the weight.
class DenyByDefaultFlagTest : public DerivedRestrictionsManagerTest {
protected:
// target — the module being reached
// declared — loaded, and declares `target` as a dependency
// undeclared — loaded, declares nothing (the shape D-a found in this
// tree: counter_qml calling package_manager with
// "dependencies": [])
void SetUp() override {
DerivedRestrictionsManagerTest::SetUp();
reg("target", {});
reg("declared", {"target"});
reg("undeclared", {});
logos_core_mark_module_loaded("target");
logos_core_mark_module_loaded("declared");
logos_core_mark_module_loaded("undeclared");
}
};
TEST_F(DenyByDefaultFlagTest, FlagOff_UndeclaredCallerStaysUnrestricted) {
// No policy installed — the default every host has today. Core derives
// nothing, so it registers NO restriction for `target`, and
// capability_module's unrestricted-target path leaves `undeclared ->
// target` working exactly as before.
EXPECT_TRUE(derived("target").empty());
// Same for a policy that isn't in enforce mode: still off, still open.
ModuleManager::setAccessPolicy(
"{\"version\":1,\"mode\":\"audit\",\"restrictions\":{}}");
EXPECT_TRUE(derived("target").empty());
}
TEST_F(DenyByDefaultFlagTest, FlagOn_DeclaredCallerAllowed_UndeclaredRefused) {
ModuleManager::setAccessPolicy(enforceEnvelope());
const auto callers = derived("target");
// A restriction IS registered now — that is what makes the target closed.
ASSERT_FALSE(callers.empty());
// The declared dependent keeps working…
EXPECT_TRUE(callers.count("declared"))
<< "enforce must not break a caller that declared the target";
// …and the undeclared caller is not on the list, so capability_module
// refuses to mint it a token.
EXPECT_FALSE(callers.count("undeclared"))
<< "enforce must refuse a caller that never declared the target";
}
TEST_F(DenyByDefaultFlagTest, FlagOn_ExplicitPolicyCanReadmitAnUndeclaredCaller) {
// The escape hatch an operator needs when a real deployment has a caller
// that legitimately can't declare its target (out-of-process ui_qml
// plugins, for one): an explicit entry replaces the derived list verbatim.
ModuleManager::setAccessPolicy(
"{\"version\":1,\"mode\":\"enforce\",\"restrictions\":{"
"\"target\":{\"allowedCallers\":[\"declared\",\"undeclared\"]}}}");
const auto callers = derived("target");
EXPECT_TRUE(callers.count("declared"));
EXPECT_TRUE(callers.count("undeclared"));
}
TEST_F(DenyByDefaultFlagTest, FlagIsReversible) {
// Clearing the policy must restore today's behaviour byte-for-byte, not
// leave a latched restriction behind (hosts call setAccessPolicy once per
// boot, but a restart in the same process must not inherit enforcement).
ModuleManager::setAccessPolicy(enforceEnvelope());
ASSERT_FALSE(derived("target").empty());
ModuleManager::setAccessPolicy("");
EXPECT_TRUE(derived("target").empty());
}