From 819faac420370e71acca37ccd917e2d0786279ca Mon Sep 17 00:00:00 2001 From: Dario Lipicar Date: Mon, 29 Jun 2026 18:36:09 -0300 Subject: [PATCH] feat: add logos_core_get_modules_info() for generic module introspection (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add logos_core_get_modules_info() for generic module introspection New C API returning a JSON array describing every known module — one object each with name, path, loaded flag, direct dependencies, direct dependents, and the full embedded metadata (parsed from the plugin's declarative metadata.json; null when unreadable). ModuleInfo now caches the raw metadata JSON at discovery (via ModuleLib::LogosModule::getRawMetadataJson, no plugin instantiation), ModuleRegistry::allModulesInfo() assembles the array under the registry lock, and ModuleManager exposes it as getModulesInfoJson()/CStr. Methods/events are intentionally excluded: they require instantiating the plugin in-process, which would defeat the subprocess-isolation model for a bulk "all known modules" query. Tests: ModuleManagerTest.GetModulesInfo_* (shape, empty) and RealModuleRegistryTest.GetModulesInfo_PopulatesEmbeddedMetadata (real plugin metadata via TEST_PLUGIN). Co-Authored-By: Claude Opus 4.8 * chore: bump logos-module to merged getRawMetadataJson (#21) Re-pin logos-module a3e288a → 2ec64c4 (master, includes #21) so the modules-info API builds against the merged getRawMetadataJson without an override. Full test suite green on this lock (181/181). Co-Authored-By: Claude Opus 4.8 * feat: record module load timestamp (loaded_at) in modules-info Add ModuleInfo::loadedAt — a unix-seconds timestamp stamped by markLoaded (both overloads) and cleared to 0 by markUnloaded, so loaded_at is 0 ⟺ not loaded (reload re-stamps it). Surfaced as "loaded_at" in logos_core_get_modules_info(), letting callers derive a module's uptime as now - loaded_at (valid only while loaded). Tests: GetModulesInfo_ReturnsRichEntryPerModule now asserts loaded_at is 0 for an unloaded module and > 0 for a loaded one. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- flake.lock | 6 +-- src/logos_core/logos_core.cpp | 4 ++ src/logos_core/logos_core.h | 14 +++++ src/logos_core/module_manager.cpp | 11 ++++ src/logos_core/module_manager.h | 7 +++ src/logos_core/module_registry.cpp | 45 +++++++++++++++- src/logos_core/module_registry.h | 14 +++++ tests/test_module_manager.cpp | 87 ++++++++++++++++++++++++++++++ 8 files changed, 183 insertions(+), 5 deletions(-) diff --git a/flake.lock b/flake.lock index c503eb5..a0d65a0 100644 --- a/flake.lock +++ b/flake.lock @@ -1417,11 +1417,11 @@ ] }, "locked": { - "lastModified": 1781311603, - "narHash": "sha256-Ym3i52f5MWuZy8Qas3+zfoxz+mxQUsyW+Qol1no6kDs=", + "lastModified": 1782766905, + "narHash": "sha256-oYE7sMr7PS+kDoX//Cx90HmRiRHBnHONx6r4Ekkc7DI=", "owner": "logos-co", "repo": "logos-module", - "rev": "a3e288a71d6f79445db598b0ffcca2ee435596b9", + "rev": "2ec64c4a65f8966b5137cdba3a19a6498ce17b6d", "type": "github" }, "original": { diff --git a/src/logos_core/logos_core.cpp b/src/logos_core/logos_core.cpp index 484a68b..2d4f61b 100644 --- a/src/logos_core/logos_core.cpp +++ b/src/logos_core/logos_core.cpp @@ -67,6 +67,10 @@ char** logos_core_get_module_dependents(const char* module_name, bool recursive) return ModuleManager::getDependentsCStr(module_name, recursive); } +char* logos_core_get_modules_info() { + return ModuleManager::getModulesInfoCStr(); +} + char* logos_core_process_module(const char* module_path) { if (!module_path) { logos::logger("core").critical("logos_core_process_module: module_path must not be null"); std::abort(); } return ModuleManager::processModuleCStr(module_path); diff --git a/src/logos_core/logos_core.h b/src/logos_core/logos_core.h index e7fd95a..275f4f3 100644 --- a/src/logos_core/logos_core.h +++ b/src/logos_core/logos_core.h @@ -70,6 +70,20 @@ LOGOS_CORE_EXPORT char** logos_core_get_module_dependencies(const char* module_n // Returns a null-terminated array of module names that must be freed by the caller. LOGOS_CORE_EXPORT char** logos_core_get_module_dependents(const char* module_name, bool recursive); +// Get information about all known modules as a JSON string. +// Returns a JSON array; each element is an object with: +// "name" module name +// "path" path to the module binary +// "loaded" bool — whether the module is currently loaded +// "loaded_at" unix-seconds timestamp of the current load, 0 when not +// loaded (callers compute uptime as now - loaded_at) +// "dependencies" array of direct dependency names +// "dependents" array of direct dependent names +// "metadata" the module's full embedded metadata object (name, version, +// type, description, dependencies, …), or null if unreadable +// The returned string must be freed by the caller. +LOGOS_CORE_EXPORT char* logos_core_get_modules_info(); + // Process a module file and add it to known modules // Returns the module name if successful, NULL if failed LOGOS_CORE_EXPORT char* logos_core_process_module(const char* module_path); diff --git a/src/logos_core/module_manager.cpp b/src/logos_core/module_manager.cpp index 9e5ecb7..2590b59 100644 --- a/src/logos_core/module_manager.cpp +++ b/src/logos_core/module_manager.cpp @@ -671,6 +671,17 @@ namespace ModuleManager { getDependents(std::string(name), recursive)); } + std::string getModulesInfoJson() { + return registryInstance().allModulesInfo().dump(); + } + + char* getModulesInfoCStr() { + std::string json = getModulesInfoJson(); + char* result = new char[json.size() + 1]; + strcpy(result, json.c_str()); + return result; + } + std::vector computeDerivedAllowedCallers(const std::string& target) { std::lock_guard lock(loadMutex()); return computeDerivedAllowedCallersLocked(target); diff --git a/src/logos_core/module_manager.h b/src/logos_core/module_manager.h index eaafd34..42824fd 100644 --- a/src/logos_core/module_manager.h +++ b/src/logos_core/module_manager.h @@ -96,6 +96,13 @@ namespace ModuleManager { // array, matching the C API contract used by the other getters. char** getDependenciesCStr(const char* name, bool recursive); char** getDependentsCStr(const char* name, bool recursive); + + // JSON (string) describing every known module: name, path, loaded flag, + // direct dependencies, direct dependents, and full embedded metadata. + // See ModuleRegistry::allModulesInfo for the shape. + std::string getModulesInfoJson(); + // char* variant. Caller owns the returned string. Never null. + char* getModulesInfoCStr(); } #endif // MODULE_MANAGER_H diff --git a/src/logos_core/module_registry.cpp b/src/logos_core/module_registry.cpp index 8006190..35ce1ec 100644 --- a/src/logos_core/module_registry.cpp +++ b/src/logos_core/module_registry.cpp @@ -1,6 +1,7 @@ #include "module_registry.h" #include #include +#include #include #include #include @@ -165,6 +166,7 @@ std::string ModuleRegistry::processModuleInternal(const std::string& modulePath, // (and any other state that lives on ModuleInfo). ModuleInfo& info = m_modules[name]; info.path = modulePath; + info.metadataJson = ModuleLib::LogosModule::getRawMetadataJson(modulePath); info.dependencies.clear(); for (const auto& d : ModuleLib::LogosModule::getModuleDependencies(modulePath)) { info.dependencies.push_back(d); @@ -184,6 +186,33 @@ std::string ModuleRegistry::modulePath(const std::string& name) const { return it != m_modules.end() ? it->second.path : std::string{}; } +nlohmann::json ModuleRegistry::allModulesInfo() const { + std::shared_lock lock(m_mutex); + nlohmann::json modules = nlohmann::json::array(); + for (const auto& [name, info] : m_modules) { + nlohmann::json entry; + entry["name"] = name; + entry["path"] = info.path; + entry["loaded"] = info.loaded; + // Unix-seconds timestamp of the current load (0 when not loaded). + // Callers compute uptime as now - loaded_at while loaded. + entry["loaded_at"] = info.loadedAt; + entry["dependencies"] = info.dependencies; + entry["dependents"] = info.dependents; + // Parse the cached metadata JSON back into structured form. Tolerate a + // missing/garbled blob by reporting null rather than aborting the call. + if (info.metadataJson.empty()) { + entry["metadata"] = nlohmann::json(nullptr); + } else { + nlohmann::json meta = nlohmann::json::parse( + info.metadataJson, nullptr, /*allow_exceptions=*/false); + entry["metadata"] = meta.is_discarded() ? nlohmann::json(nullptr) : meta; + } + modules.push_back(std::move(entry)); + } + return modules; +} + std::vector ModuleRegistry::moduleDependencies(const std::string& name, bool recursive) const { std::shared_lock lock(m_mutex); @@ -322,9 +351,18 @@ bool ModuleRegistry::isLoaded(const std::string& name) const { return it != m_modules.end() && it->second.loaded; } +// Current wall-clock time in unix seconds. Stamped on load so callers can +// derive a module's uptime; a free function so both markLoaded overloads +// agree on the source. +static int64_t nowUnixSeconds() { + return static_cast(std::time(nullptr)); +} + void ModuleRegistry::markLoaded(const std::string& name) { std::unique_lock lock(m_mutex); - m_modules[name].loaded = true; + auto& info = m_modules[name]; + info.loaded = true; + info.loadedAt = nowUnixSeconds(); } void ModuleRegistry::markLoaded(const std::string& name, @@ -333,6 +371,7 @@ void ModuleRegistry::markLoaded(const std::string& name, std::unique_lock lock(m_mutex); auto& info = m_modules[name]; info.loaded = true; + info.loadedAt = nowUnixSeconds(); info.loader = std::move(loader); info.handle = std::move(handle); } @@ -348,8 +387,10 @@ ModuleRegistry::loaderFor(const std::string& name) const { void ModuleRegistry::markUnloaded(const std::string& name) { std::unique_lock lock(m_mutex); auto it = m_modules.find(name); - if (it != m_modules.end()) + if (it != m_modules.end()) { it->second.loaded = false; + it->second.loadedAt = 0; + } } std::vector ModuleRegistry::loadedModuleNames() const { diff --git a/src/logos_core/module_registry.h b/src/logos_core/module_registry.h index 747601e..cc16c33 100644 --- a/src/logos_core/module_registry.h +++ b/src/logos_core/module_registry.h @@ -23,6 +23,10 @@ bool isValidModuleName(const std::string& name); struct ModuleInfo { std::string path; + // The module's full embedded metadata as a compact JSON string, read once + // at discovery time via ModuleLib::LogosModule (no plugin instantiation). + // Empty when the plugin exposes no readable metadata. + std::string metadataJson; std::vector dependencies; // Direct reverse edges — names of modules whose `dependencies` list // includes this module. Kept in sync with `dependencies` across every @@ -30,6 +34,10 @@ struct ModuleInfo { // directly. Use ModuleRegistry::moduleDependents() for transitive walks. std::vector dependents; bool loaded = false; + // Unix timestamp (seconds) of the most recent load, set by markLoaded and + // cleared to 0 by markUnloaded. 0 ⟺ not currently loaded. Callers derive a + // module's uptime from it (now - loadedAt), valid only while loaded. + int64_t loadedAt = 0; // Null when loaded directly via markLoaded(name) (test/external scenarios). std::shared_ptr loader; LogosCore::LoadedModuleHandle handle; @@ -46,6 +54,12 @@ public: bool isKnown(const std::string& name) const; std::string modulePath(const std::string& name) const; + // A JSON array describing every known module: one object per module with + // its name, path, loaded flag, load timestamp (loaded_at, unix seconds; 0 + // when not loaded), direct dependencies, direct dependents, and full + // embedded metadata (parsed from the cached metadata JSON; null when + // unreadable). This is the data backing logos_core_get_modules_info. + nlohmann::json allModulesInfo() const; // Forward-edge accessor. `recursive=false` returns the direct // dependencies stored on ModuleInfo. `recursive=true` walks the forward // graph breadth-first and returns every transitive dependency. Unknown diff --git a/tests/test_module_manager.cpp b/tests/test_module_manager.cpp index 39ceb14..90972a0 100644 --- a/tests/test_module_manager.cpp +++ b/tests/test_module_manager.cpp @@ -146,6 +146,66 @@ TEST_F(ModuleManagerTest, GetKnownModules_ReturnsCorrectHash) { 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(), "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(), "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); } @@ -626,6 +686,33 @@ TEST_F(RealModuleRegistryTest, ProcessModule_RegistersRealModule) { 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). //