// 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 #include #include #include #include #include #include "test_daemon_harness.h" #include #include #include #include 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& 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 bytes; bytes.reserve(content.size()); for (uint8_t b : content) { bytes.push_back(static_cast(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::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(content.data()), content.size()); out.close(); return hash; } // Get KG store for direct queries std::shared_ptr 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(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 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 bytes; for (char c : textContent) { bytes.push_back(static_cast(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); } }