yams/tests/unit/daemon/external_plugin_host_test.cpp
trvon c39ed84bf7
refactor(plugins)!: remove the symbol_extractor_v1 plugin ABI
Migration 40 dropped the symbol tables and graph edges, but the daemon
could still adopt an external symbol_extractor_v1 plugin (for example a
0.19 plugin left in a user plugin directory) and rebuild them.

Remove the interface header, the ABI adapter, plugin adoption, the
EntityGraphService extract-and-write path, the post-ingest symbol
channel, the write-coordinator symbol ops, and the KG-store symbol
metadata and extraction-state API. The ABI loader and the external host
now refuse a plugin that declares the interface (or the legacy
symbol_extraction capability) with a one-time warning. Status no longer
reports symbol extractors, generated configs no longer write
[plugins.symbol_extraction] (an existing section still parses), and
the benchmark suite drops symbol_extraction_bench.

Tests that exercised the dropped tables or the v39 schema template are
removed; the rest run against the current schema.

BREAKING CHANGE: code-symbol extraction is gone. Removed: the
symbol_extractor_v1 plugin ABI, `yams config grammar` (list, download,
path, auto-enable, auto-disable), the grammar steps in `yams init`, the
meson option `plugin-symbols`, the conan option
`enable_symbol_extraction`, and the Windows installer SymbolPlugin
component. Databases migrate to schema 41; a v0.19 binary refuses to
open them, and symbol data dropped by migration 40 cannot be recovered.
2026-09-22 13:30:35 -06:00

535 lines
20 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2025 Trevon Sides
#include <nlohmann/json.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_string.hpp>
#include <yams/daemon/resource/external_plugin_host.h>
#include <chrono>
#include <filesystem>
#include <fstream>
namespace yams::daemon {
namespace fs = std::filesystem;
struct ExternalPluginHostFixture {
ExternalPluginHostFixture() {
tempDir_ = fs::temp_directory_path() / ("yams_eph_" + std::to_string(time(nullptr)));
fs::create_directories(tempDir_);
trustFile_ = tempDir_ / "external_plugins_trust.txt";
}
~ExternalPluginHostFixture() {
std::error_code ec;
fs::remove_all(tempDir_, ec);
}
fs::path makeFile(std::string_view name, std::string_view content = "") {
auto p = tempDir_ / name;
std::ofstream{p, std::ios::binary} << content;
return p;
}
fs::path tempDir_;
fs::path trustFile_;
};
TEST_CASE("ExternalPluginHost - Static File Detection", "[external-plugin][host]") {
SECTION("Python files are detected") {
auto temp = fs::temp_directory_path() / "test_plugin.py";
std::ofstream{temp} << "# test";
CHECK(ExternalPluginHost::isExternalPluginFile(temp));
fs::remove(temp);
}
SECTION("JavaScript files are detected") {
auto temp = fs::temp_directory_path() / "test_plugin.js";
std::ofstream{temp} << "// test";
CHECK(ExternalPluginHost::isExternalPluginFile(temp));
fs::remove(temp);
}
SECTION("C++ files are not detected") {
auto temp = fs::temp_directory_path() / "test.cpp";
std::ofstream{temp} << "// test";
CHECK_FALSE(ExternalPluginHost::isExternalPluginFile(temp));
fs::remove(temp);
}
SECTION("Non-existent files return false") {
CHECK_FALSE(ExternalPluginHost::isExternalPluginFile("/nonexistent/path.py"));
}
}
TEST_CASE("ExternalPluginHost - Supported Extensions", "[external-plugin][host]") {
auto exts = ExternalPluginHost::supportedExtensions();
REQUIRE(exts.size() >= 2);
bool hasPython = false;
bool hasJs = false;
for (const auto& ext : exts) {
if (ext == ".py")
hasPython = true;
if (ext == ".js")
hasJs = true;
}
CHECK(hasPython);
CHECK(hasJs);
}
TEST_CASE("ExternalPluginHost - Construction and Destruction", "[external-plugin][host]") {
ExternalPluginHostFixture fixture;
SECTION("Default construction") {
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
// Host should be constructible with nullptr ServiceManager
auto loaded = host.listLoaded();
CHECK(loaded.empty());
}
SECTION("Construction with custom config") {
ExternalPluginHostConfig config;
config.maxPlugins = 5;
config.defaultRpcTimeout = std::chrono::milliseconds{5000};
config.pythonExecutable = "python";
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
CHECK(host.listLoaded().empty());
}
}
TEST_CASE("ExternalPluginHost - Trust Management", "[external-plugin][host][trust]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
SECTION("Initially empty trust list") {
auto trustList = host.trustList();
CHECK(trustList.empty());
}
SECTION("Add and remove from trust list") {
auto pluginPath = fixture.makeFile("trusted_plugin.py", "# trusted");
// Add to trust
auto addResult = host.trustAdd(pluginPath);
REQUIRE(addResult.has_value());
auto trustList = host.trustList();
REQUIRE(trustList.size() == 1);
// Verify canonical path is stored
auto canonical = fs::weakly_canonical(pluginPath);
bool found = std::find(trustList.begin(), trustList.end(), canonical) != trustList.end();
CHECK(found);
// Remove from trust
auto removeResult = host.trustRemove(pluginPath);
REQUIRE(removeResult.has_value());
CHECK(host.trustList().empty());
}
SECTION("Trust list persists") {
auto pluginPath = fixture.makeFile("persistent_plugin.py", "# persist");
{
ExternalPluginHost host1(nullptr, fixture.trustFile_, config);
host1.trustAdd(pluginPath);
}
// Check file was written
REQUIRE(fs::exists(fixture.trustFile_));
{
ExternalPluginHost host2(nullptr, fixture.trustFile_, config);
auto trustList = host2.trustList();
CHECK(trustList.size() == 1);
}
}
#if !defined(_WIN32)
SECTION("Trust file permissions are private") {
auto pluginPath = fixture.makeFile("private_perms_plugin.py", "# perms");
auto addResult = host.trustAdd(pluginPath);
REQUIRE(addResult.has_value());
REQUIRE(fs::exists(fixture.trustFile_));
std::error_code ec;
auto perms = fs::status(fixture.trustFile_, ec).permissions();
REQUIRE_FALSE(ec);
CHECK((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
}
#endif
SECTION("Trust remove persists") {
auto plugin1 = fixture.makeFile("persist_remove_a.py", "# a");
auto plugin2 = fixture.makeFile("persist_remove_b.py", "# b");
{
ExternalPluginHost host1(nullptr, fixture.trustFile_, config);
REQUIRE(host1.trustAdd(plugin1).has_value());
REQUIRE(host1.trustAdd(plugin2).has_value());
REQUIRE(host1.trustRemove(plugin1).has_value());
}
{
ExternalPluginHost host2(nullptr, fixture.trustFile_, config);
auto trustList = host2.trustList();
auto c1 = fs::weakly_canonical(plugin1);
auto c2 = fs::weakly_canonical(plugin2);
bool has1 = std::find(trustList.begin(), trustList.end(), c1) != trustList.end();
bool has2 = std::find(trustList.begin(), trustList.end(), c2) != trustList.end();
CHECK_FALSE(has1);
CHECK(has2);
}
}
SECTION("Adding already-trusted path is idempotent") {
auto pluginPath = fixture.makeFile("idempotent_plugin.py", "# test");
// Add first time
auto addResult1 = host.trustAdd(pluginPath);
REQUIRE(addResult1.has_value());
CHECK(host.trustList().size() == 1);
// Add second time - should succeed without error and not duplicate
auto addResult2 = host.trustAdd(pluginPath);
REQUIRE(addResult2.has_value());
CHECK(host.trustList().size() == 1);
}
SECTION("Trust paths with varying depth levels") {
// Create 10 paths with varying depths
std::vector<fs::path> paths;
// Depth 0 - root level file
paths.push_back(fixture.makeFile("plugin_root.py", "# root"));
// Depth 1
fs::create_directories(fixture.tempDir_ / "level1");
paths.push_back(fixture.tempDir_ / "level1" / "plugin_d1.py");
std::ofstream{paths.back()} << "# depth 1";
// Depth 2
fs::create_directories(fixture.tempDir_ / "level1" / "level2");
paths.push_back(fixture.tempDir_ / "level1" / "level2" / "plugin_d2.py");
std::ofstream{paths.back()} << "# depth 2";
// Depth 3
fs::create_directories(fixture.tempDir_ / "a" / "b" / "c");
paths.push_back(fixture.tempDir_ / "a" / "b" / "c" / "plugin_d3.py");
std::ofstream{paths.back()} << "# depth 3";
// Depth 4
fs::create_directories(fixture.tempDir_ / "deep" / "nested" / "path" / "here");
paths.push_back(fixture.tempDir_ / "deep" / "nested" / "path" / "here" / "plugin_d4.py");
std::ofstream{paths.back()} << "# depth 4";
// Depth 5
fs::create_directories(fixture.tempDir_ / "x" / "y" / "z" / "w" / "v");
paths.push_back(fixture.tempDir_ / "x" / "y" / "z" / "w" / "v" / "plugin_d5.py");
std::ofstream{paths.back()} << "# depth 5";
// More depth 1 variations
fs::create_directories(fixture.tempDir_ / "plugins");
paths.push_back(fixture.tempDir_ / "plugins" / "my_plugin.py");
std::ofstream{paths.back()} << "# plugins dir";
// Path with special characters (spaces, dashes)
fs::create_directories(fixture.tempDir_ / "my-plugins" / "sub dir");
paths.push_back(fixture.tempDir_ / "my-plugins" / "sub dir" / "special_plugin.py");
std::ofstream{paths.back()} << "# special chars";
// Directory trust (not file)
fs::create_directories(fixture.tempDir_ / "trusted_dir");
paths.push_back(fixture.tempDir_ / "trusted_dir");
// Another deep path
fs::create_directories(fixture.tempDir_ / "org" / "company" / "project" / "plugins");
paths.push_back(fixture.tempDir_ / "org" / "company" / "project" / "plugins" / "plugin.py");
std::ofstream{paths.back()} << "# org path";
REQUIRE(paths.size() == 10);
// Add all paths to trust
for (size_t i = 0; i < paths.size(); ++i) {
INFO("Adding trust for path " << i << ": " << paths[i].string());
auto result = host.trustAdd(paths[i]);
REQUIRE(result.has_value());
}
// Verify all paths are trusted
auto trustList = host.trustList();
CHECK(trustList.size() == 10);
// Re-add all paths (idempotency check)
for (size_t i = 0; i < paths.size(); ++i) {
INFO("Re-adding trust for path " << i << ": " << paths[i].string());
auto result = host.trustAdd(paths[i]);
REQUIRE(result.has_value());
}
// Count should still be 10 (no duplicates)
trustList = host.trustList();
CHECK(trustList.size() == 10);
// Verify each canonical path is in the list
for (const auto& path : paths) {
auto canonical = fs::weakly_canonical(path);
bool found =
std::find(trustList.begin(), trustList.end(), canonical) != trustList.end();
INFO("Checking path: " << canonical.string());
CHECK(found);
}
// Remove paths one by one and verify count decreases
for (size_t i = 0; i < paths.size(); ++i) {
auto result = host.trustRemove(paths[i]);
REQUIRE(result.has_value());
CHECK(host.trustList().size() == (10 - i - 1));
}
CHECK(host.trustList().empty());
}
}
TEST_CASE("ExternalPluginHost - Scan Invalid Files", "[external-plugin][host][scan]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
SECTION("Scan non-existent file returns error") {
auto result = host.scanTarget("/nonexistent/plugin.py");
REQUIRE_FALSE(result.has_value());
CHECK(result.error().code == ErrorCode::InvalidArgument);
}
SECTION("Scan non-plugin file returns error") {
auto textFile = fixture.makeFile("readme.txt", "Not a plugin");
auto result = host.scanTarget(textFile);
REQUIRE_FALSE(result.has_value());
CHECK(result.error().code == ErrorCode::InvalidArgument);
}
}
TEST_CASE("ExternalPluginHost - Load Non-Existent Plugin", "[external-plugin][host][load]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
auto result = host.load("/nonexistent/plugin.py", "{}");
REQUIRE_FALSE(result.has_value());
CHECK((result.error().code == ErrorCode::FileNotFound));
}
TEST_CASE("ExternalPluginHost - Unload Non-Existent Plugin", "[external-plugin][host][unload]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
auto result = host.unload("nonexistent-plugin");
REQUIRE_FALSE(result.has_value());
CHECK((result.error().code == ErrorCode::NotFound));
}
TEST_CASE("ExternalPluginHost - Health Check Non-Existent Plugin",
"[external-plugin][host][health]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
auto result = host.health("nonexistent-plugin");
REQUIRE_FALSE(result.has_value());
CHECK((result.error().code == ErrorCode::NotFound));
}
TEST_CASE("ExternalPluginHost - RPC Call Non-Existent Plugin", "[external-plugin][host][rpc]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
nlohmann::json emptyParams = nlohmann::json::object();
auto result = host.callRpc("nonexistent-plugin", "test.method", emptyParams);
REQUIRE_FALSE(result.has_value());
CHECK((result.error().code == ErrorCode::NotFound));
}
TEST_CASE("ExternalPluginHost - Scan Empty Directory", "[external-plugin][host][scan]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
auto emptyDir = fixture.tempDir_ / "empty";
fs::create_directories(emptyDir);
auto result = host.scanDirectory(emptyDir);
REQUIRE(result.has_value());
CHECK(result.value().empty());
}
TEST_CASE("ExternalPluginHost - Scan Non-Existent Directory", "[external-plugin][host][scan]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
auto result = host.scanDirectory("/nonexistent/directory");
REQUIRE_FALSE(result.has_value());
CHECK((result.error().code == ErrorCode::InvalidPath));
}
TEST_CASE("ExternalPluginHost - Get Stats Empty", "[external-plugin][host][stats]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
auto stats = host.getStats();
REQUIRE(stats.contains("total_loaded"));
CHECK((stats["total_loaded"].get<size_t>() == 0));
REQUIRE(stats.contains("plugins"));
CHECK(stats["plugins"].is_array());
CHECK(stats["plugins"].empty());
}
TEST_CASE("ExternalPluginHost - State Callback", "[external-plugin][host][callback]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
std::vector<std::pair<std::string, std::string>> events;
host.setStateCallback([&events](const std::string& name, const std::string& event) {
events.emplace_back(name, event);
});
// Callback shouldn't be called for operations that fail
host.unload("nonexistent");
CHECK(events.empty());
}
TEST_CASE("ExternalPluginHost - Plugin Manifest Interfaces Parsing",
"[external-plugin][host][manifest][interfaces]") {
ExternalPluginHostFixture fixture;
ExternalPluginHostConfig config;
ExternalPluginHost host(nullptr, fixture.trustFile_, config);
SECTION("String interfaces array is parsed correctly") {
// Create plugin directory with manifest containing string interfaces
auto pluginDir = fixture.tempDir_ / "test_plugin";
fs::create_directories(pluginDir);
// Write yams-plugin.json with string interfaces
nlohmann::json manifest;
manifest["name"] = "test_interfaces_plugin";
manifest["version"] = "1.0.0";
manifest["interfaces"] =
nlohmann::json::array({"content_extractor_v1", "kg_entity_provider_v1"});
manifest["entry"] = {{"fallback_cmd", nlohmann::json::array({"python", "plugin.py"})}};
std::ofstream manifestFile(pluginDir / "yams-plugin.json");
manifestFile << manifest.dump(2);
manifestFile.close();
// Create a dummy plugin.py
std::ofstream pluginFile(pluginDir / "plugin.py");
pluginFile << "# Dummy plugin";
pluginFile.close();
// Scan the plugin directory
auto result = host.scanTarget(pluginDir);
REQUIRE(result.has_value());
const auto& desc = result.value();
CHECK((desc.name == "test_interfaces_plugin"));
CHECK((desc.version == "1.0.0"));
// Verify interfaces were parsed
REQUIRE((desc.interfaces.size() == 2));
CHECK((desc.interfaces[0] == "content_extractor_v1"));
CHECK((desc.interfaces[1] == "kg_entity_provider_v1"));
}
SECTION("Empty interfaces array results in empty interfaces") {
auto pluginDir = fixture.tempDir_ / "empty_interfaces_plugin";
fs::create_directories(pluginDir);
nlohmann::json manifest;
manifest["name"] = "empty_interfaces_plugin";
manifest["version"] = "1.0.0";
manifest["interfaces"] = nlohmann::json::array();
manifest["entry"] = {{"fallback_cmd", nlohmann::json::array({"python", "plugin.py"})}};
std::ofstream manifestFile(pluginDir / "yams-plugin.json");
manifestFile << manifest.dump(2);
manifestFile.close();
std::ofstream pluginFile(pluginDir / "plugin.py");
pluginFile << "# Dummy plugin";
pluginFile.close();
auto result = host.scanTarget(pluginDir);
REQUIRE(result.has_value());
CHECK(result.value().interfaces.empty());
}
SECTION("Interfaces inferred from capabilities when interfaces not present") {
auto pluginDir = fixture.tempDir_ / "capabilities_plugin";
fs::create_directories(pluginDir);
nlohmann::json manifest;
manifest["name"] = "capabilities_plugin";
manifest["version"] = "1.0.0";
manifest["capabilities"] = {
{"content_extraction", {{"formats", nlohmann::json::array({"text/plain"})}}},
{"graph_store", nlohmann::json::object()}};
manifest["entry"] = {{"fallback_cmd", nlohmann::json::array({"python", "plugin.py"})}};
std::ofstream manifestFile(pluginDir / "yams-plugin.json");
manifestFile << manifest.dump(2);
manifestFile.close();
std::ofstream pluginFile(pluginDir / "plugin.py");
pluginFile << "# Dummy plugin";
pluginFile.close();
auto result = host.scanTarget(pluginDir);
REQUIRE(result.has_value());
const auto& desc = result.value();
// Should have inferred interfaces from capabilities
REQUIRE((desc.interfaces.size() == 2));
CHECK((std::find(desc.interfaces.begin(), desc.interfaces.end(), "content_extractor_v1") !=
desc.interfaces.end()));
CHECK((std::find(desc.interfaces.begin(), desc.interfaces.end(), "graph_store_v1") !=
desc.interfaces.end()));
}
SECTION("Manifests declaring the removed symbol extraction capability are refused") {
auto pluginDir = fixture.tempDir_ / "symbol_plugin";
fs::create_directories(pluginDir);
nlohmann::json manifest;
manifest["name"] = "symbol_plugin";
manifest["version"] = "1.0.0";
manifest["capabilities"] = {
{"symbol_extraction", {{"languages", nlohmann::json::array({"python"})}}}};
manifest["entry"] = {{"fallback_cmd", nlohmann::json::array({"python", "plugin.py"})}};
std::ofstream manifestFile(pluginDir / "yams-plugin.json");
manifestFile << manifest.dump(2);
manifestFile.close();
std::ofstream pluginFile(pluginDir / "plugin.py");
pluginFile << "# Dummy plugin";
pluginFile.close();
auto result = host.scanTarget(pluginDir);
REQUIRE_FALSE(result.has_value());
CHECK((result.error().code == ErrorCode::NotSupported));
CHECK((result.error().message.find("symbol_extractor_v1") != std::string::npos));
}
}
} // namespace yams::daemon