yams/tests/integration/daemon/ghidra_kg_entity_ingestion_test.cpp
trvon d67d5d63ac
Remediate Fold SAST findings
Centralize duplicated helpers and harden numeric, timing, IPC, SQLite retry, config, plugin, metrics, and async content-store paths identified during Fold SAST triage.\n\nAdd targeted tests for shared utilities and preserve per-task YAMS checkpoints.
2026-05-27 22:41:44 -04:00

426 lines
16 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright 2025 YAMS Contributors
//
// Integration test: Ghidra plugin → KG entity ingestion pipeline
// Tests end-to-end flow: binary analysis → entity extraction → KG node/edge creation
// Part of yams-3jb: Add integration test for Ghidra KG entity ingestion
#include <chrono>
#include <filesystem>
#include <fstream>
#include <thread>
#include <spdlog/spdlog.h>
#include <catch2/catch_test_macros.hpp>
#include "../../common/test_helpers_catch2.h"
#include "test_daemon_harness.h"
#include <yams/daemon/components/ServiceManager.h>
#include <yams/daemon/components/TuneAdvisor.h>
#include <yams/daemon/daemon.h>
#include <yams/metadata/knowledge_graph_store.h>
using namespace yams::metadata;
// Skip tests on Windows where daemon IPC shutdown is unstable
#ifdef _WIN32
#define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() \
SKIP("Windows daemon shutdown unstable - run in Docker container")
#else
#define SKIP_ON_WINDOWS_DAEMON_SHUTDOWN() ((void)0)
#endif
// ============================================================================
// Test Fixture: Ghidra Entity Extraction Integration
// ============================================================================
class GhidraEntityIntegrationFixture {
public:
GhidraEntityIntegrationFixture() {
if (!harness_.start(std::chrono::seconds(30))) {
throw std::runtime_error("DaemonHarness failed to start");
}
daemon_ = harness_.daemon();
if (!daemon_) {
throw std::runtime_error("Daemon not available after harness start");
}
serviceManager_ = daemon_->getServiceManager();
if (!serviceManager_) {
throw std::runtime_error("ServiceManager not available");
}
testDir_ = harness_.dataDir();
spdlog::info("GhidraEntityIntegrationFixture: Daemon started, dataDir={}",
testDir_.string());
}
~GhidraEntityIntegrationFixture() {
spdlog::debug("GhidraEntityIntegrationFixture: Cleanup via DaemonHarness");
}
// Check if any entity providers are available
bool hasEntityProviders() {
// Entity providers are loaded via PluginManager
// For now, check if the entity channel is active
auto pq = serviceManager_->getPostIngestQueue();
if (!pq)
return false;
// Check via entity inflight counter existence (non-zero max concurrent)
return pq->maxEntityConcurrent() > 0;
}
// Store a binary file for testing
std::string storeBinary(const std::string& filename, const std::vector<uint8_t>& content) {
auto contentStore = serviceManager_->getContentStore();
if (!contentStore) {
throw std::runtime_error("ContentStore not available");
}
auto meta = serviceManager_->getMetadataRepo();
if (!meta) {
throw std::runtime_error("MetadataRepository not available");
}
std::vector<std::byte> bytes;
bytes.reserve(content.size());
for (uint8_t b : content) {
bytes.push_back(static_cast<std::byte>(b));
}
auto storeResult = contentStore->storeBytes(bytes);
if (!storeResult) {
throw std::runtime_error("Failed to store content: " + storeResult.error().message);
}
std::string hash = storeResult.value().contentHash;
yams::metadata::DocumentInfo doc;
doc.fileName = filename;
doc.filePath = (testDir_ / filename).string();
doc.sha256Hash = hash;
doc.fileSize = content.size();
doc.mimeType = "application/x-executable";
doc.fileExtension = ".exe";
doc.modifiedTime =
std::chrono::time_point_cast<std::chrono::seconds>(std::chrono::system_clock::now());
auto insertResult = meta->insertDocument(doc);
if (!insertResult) {
throw std::runtime_error("Failed to insert document: " + insertResult.error().message);
}
// Write to disk for plugin access
std::ofstream out(doc.filePath, std::ios::binary);
out.write(reinterpret_cast<const char*>(content.data()),
yams::test::checked_streamsize(content.size(), "Ghidra fixture binary"));
out.close();
return hash;
}
// Get KG store for direct queries
std::shared_ptr<KnowledgeGraphStore> getKgStore() { return serviceManager_->getKgStore(); }
// Wait for entity extraction to complete
bool waitForEntityProcessing(int timeoutMs = 10000) {
auto pq = serviceManager_->getPostIngestQueue();
if (!pq)
return false;
auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs);
while (std::chrono::steady_clock::now() < deadline) {
if (pq->entityInFlight() == 0 && pq->size() == 0) {
std::this_thread::sleep_for(
std::chrono::milliseconds(200)); // Allow final processing
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
yams::test::DaemonHarness harness_;
yams::daemon::YamsDaemon* daemon_{nullptr};
yams::daemon::ServiceManager* serviceManager_{nullptr};
std::filesystem::path testDir_;
};
// ============================================================================
// Test Cases: Entity Extraction Pipeline Validation
// ============================================================================
TEST_CASE("GhidraEntityIngestion: KG store is available for entity storage",
"[integration][daemon][ghidra][pbi-3jb]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
GhidraEntityIntegrationFixture fixture;
SECTION("KnowledgeGraphStore is initialized") {
auto kg = fixture.getKgStore();
REQUIRE(kg != nullptr);
auto healthResult = kg->healthCheck();
REQUIRE(healthResult.has_value());
spdlog::info("KG store health check passed - ready for entity storage");
}
}
TEST_CASE("GhidraEntityIngestion: Entity provider infrastructure is ready",
"[integration][daemon][ghidra][pbi-3jb]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
GhidraEntityIntegrationFixture fixture;
SECTION("Entity processing channel is configured") {
auto pq = fixture.serviceManager_->getPostIngestQueue();
REQUIRE(pq != nullptr);
// Verify entity channel configuration
size_t maxConcurrent = pq->maxEntityConcurrent();
const uint32_t mask = yams::daemon::TuneAdvisor::postIngestStageActiveMask();
const uint32_t entityBit =
1u << static_cast<uint8_t>(yams::daemon::TuneAdvisor::PostIngestStage::Entity);
const bool entityStageActive = (mask & entityBit) != 0;
// Entity extraction concurrency is dynamically budgeted based on whether any
// ExternalEntityProviderAdapter instances are present. In the default integration-test
// harness, plugins are not autoloaded, so no entity providers are adopted and the entity
// stage is inactive (max concurrent == 0).
spdlog::info("Entity channel configured with max concurrent: {} (active={})", maxConcurrent,
entityStageActive);
if (entityStageActive) {
REQUIRE(maxConcurrent > 0);
} else {
REQUIRE(maxConcurrent == 0);
}
}
SECTION("Entity inflight counter is accessible") {
auto pq = fixture.serviceManager_->getPostIngestQueue();
REQUIRE(pq != nullptr);
size_t entityInFlight = pq->entityInFlight();
spdlog::info("Current entity inflight count: {}", entityInFlight);
// Counter should be accessible (value doesn't matter)
REQUIRE(entityInFlight >= 0);
}
}
TEST_CASE("GhidraEntityIngestion: Binary node types are recognized in KG",
"[integration][daemon][ghidra][pbi-3jb]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
GhidraEntityIntegrationFixture fixture;
SECTION("KG can store binary.function nodes") {
auto kg = fixture.getKgStore();
REQUIRE(kg != nullptr);
// Create a test binary.function node manually to verify schema
KGNode funcNode;
funcNode.type = "binary.function";
funcNode.nodeKey = "test_binary_func_12345";
funcNode.label = "main";
funcNode.properties = R"({"address": "0x401000", "size": 256})";
auto insertResult = kg->upsertNode(funcNode);
if (insertResult.has_value()) {
spdlog::info("binary.function node inserted with id: {}", insertResult.value());
REQUIRE(insertResult.value() > 0);
// Clean up test node
kg->deleteNodeById(insertResult.value());
} else {
spdlog::warn("Could not insert test node - may need schema update");
}
}
SECTION("KG can store binary.import nodes") {
auto kg = fixture.getKgStore();
REQUIRE(kg != nullptr);
KGNode importNode;
importNode.type = "binary.import";
importNode.nodeKey = "test_import_kernel32_CreateFileW";
importNode.label = "CreateFileW";
importNode.properties = R"({"library": "kernel32.dll"})";
auto insertResult = kg->upsertNode(importNode);
if (insertResult.has_value()) {
spdlog::info("binary.import node inserted with id: {}", insertResult.value());
REQUIRE(insertResult.value() > 0);
// Clean up test node
kg->deleteNodeById(insertResult.value());
} else {
spdlog::warn("Could not insert binary.import node - may need schema update");
}
}
SECTION("KG can store CALLS edges") {
auto kg = fixture.getKgStore();
REQUIRE(kg != nullptr);
// Create source and target nodes first
KGNode srcNode;
srcNode.type = "binary.function";
srcNode.nodeKey = "test_caller_func";
srcNode.label = "caller";
KGNode dstNode;
dstNode.type = "binary.function";
dstNode.nodeKey = "test_callee_func";
dstNode.label = "callee";
auto srcResult = kg->upsertNode(srcNode);
auto dstResult = kg->upsertNode(dstNode);
if (srcResult.has_value() && dstResult.has_value()) {
KGEdge edge;
edge.srcNodeId = srcResult.value();
edge.dstNodeId = dstResult.value();
edge.relation = "calls";
auto edgeResult = kg->addEdge(edge);
if (edgeResult.has_value()) {
spdlog::info("CALLS edge inserted with id: {}", edgeResult.value());
REQUIRE(edgeResult.value() > 0);
}
// Clean up test nodes (cascades to edges)
kg->deleteNodeById(srcResult.value());
kg->deleteNodeById(dstResult.value());
}
}
}
TEST_CASE("GhidraEntityIngestion: Entity metrics are exposed",
"[integration][daemon][ghidra][pbi-3jb]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
GhidraEntityIntegrationFixture fixture;
SECTION("Entity queued/consumed/dropped counters are accessible") {
auto pq = fixture.serviceManager_->getPostIngestQueue();
REQUIRE(pq != nullptr);
// These counters are read from InternalEventBus via DaemonMetrics
// Just verify they're accessible - values depend on processing state
size_t entityInFlight = pq->entityInFlight();
spdlog::info("Entity inflight: {}", entityInFlight);
// The counter access itself is the test - no specific value required
REQUIRE(true);
}
}
// ============================================================================
// Optional: Full Ghidra Integration Test (requires Ghidra to be installed)
// ============================================================================
TEST_CASE("GhidraEntityIngestion: Full binary analysis creates KG entities",
"[integration][daemon][ghidra][pbi-3jb][.ghidra-full]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
// This test is tagged with [.ghidra-full] - it's disabled by default
// Run with: --run-hidden to include this test
GhidraEntityIntegrationFixture fixture;
// Skip if no entity providers are available
if (!fixture.hasEntityProviders()) {
SKIP("No entity providers available - Ghidra plugin not loaded");
}
SECTION("Analyze small binary and verify KG entities") {
// Create a minimal PE header (not a real executable, just enough structure)
// This is a placeholder - real test would use an actual small binary
std::vector<uint8_t> minimalPE = {
'M', 'Z', 0x90, 0x00, // DOS signature
0x03, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00,
};
// Pad to reasonable size
minimalPE.resize(512, 0);
auto hash = fixture.storeBinary("test_binary.exe", minimalPE);
spdlog::info("Stored test binary with hash: {}", hash.substr(0, 12));
// Wait for entity extraction
bool processed = fixture.waitForEntityProcessing(30000);
if (!processed) {
spdlog::warn("Entity processing timed out - Ghidra may not be configured");
SKIP("Entity processing timed out - Ghidra may not be installed");
}
auto kg = fixture.getKgStore();
REQUIRE(kg != nullptr);
// Query for binary-related nodes
auto binaryFuncNodes = kg->findNodesByType("binary.function", 100, 0);
auto binaryImportNodes = kg->findNodesByType("binary.import", 100, 0);
if (binaryFuncNodes.has_value()) {
spdlog::info("Found {} binary.function nodes", binaryFuncNodes.value().size());
}
if (binaryImportNodes.has_value()) {
spdlog::info("Found {} binary.import nodes", binaryImportNodes.value().size());
}
// For a real binary, we'd expect:
// - binary node with SHA256
// - binary.function nodes
// - binary.import nodes
// - CALLS edges between functions
// - IMPORTS edges to libraries
// This minimal PE may not produce entities - real test needs real binary
spdlog::info("Full Ghidra integration test completed");
}
}
// ============================================================================
// Edge Cases and Error Recovery
// ============================================================================
TEST_CASE("GhidraEntityIngestion: Non-binary files don't trigger entity extraction",
"[integration][daemon][ghidra][pbi-3jb]") {
SKIP_ON_WINDOWS_DAEMON_SHUTDOWN();
GhidraEntityIntegrationFixture fixture;
SECTION("Text file is not sent to entity provider") {
auto pq = fixture.serviceManager_->getPostIngestQueue();
REQUIRE(pq != nullptr);
size_t entityInflightBefore = pq->entityInFlight();
// Store a text file (not binary)
auto contentStore = fixture.serviceManager_->getContentStore();
auto meta = fixture.serviceManager_->getMetadataRepo();
if (!contentStore || !meta) {
SKIP("Content store or metadata repo not available");
}
std::string textContent = "Hello, this is a text file.";
std::vector<std::byte> bytes;
for (char c : textContent) {
bytes.push_back(static_cast<std::byte>(c));
}
auto storeResult = contentStore->storeBytes(bytes);
REQUIRE(storeResult.has_value());
std::this_thread::sleep_for(std::chrono::milliseconds(500));
// Entity inflight should not have increased significantly
size_t entityInflightAfter = pq->entityInFlight();
spdlog::info("Entity inflight: before={}, after={}", entityInflightBefore,
entityInflightAfter);
// Text files shouldn't trigger entity extraction (may briefly increase during dispatch
// check)
CHECK(entityInflightAfter <= entityInflightBefore + 1);
}
}