1432 lines
57 KiB
C++
1432 lines
57 KiB
C++
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
// Copyright (c) 2025 Trevon Helm
|
|
// Consolidated protocol tests (6 → 1): message framing, serialization, type mappings, roundtrip
|
|
// Covers: MessageFramer, ProtoSerializer, response_of.hpp, getMessageType, getRequestName
|
|
|
|
#include <atomic>
|
|
#include <filesystem>
|
|
#include <random>
|
|
#include <thread>
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/generators/catch_generators.hpp>
|
|
#include <yams/common/utf8_utils.h>
|
|
#include <yams/daemon/ipc/ipc_protocol.h>
|
|
#include <yams/daemon/ipc/message_framing.h>
|
|
#include <yams/daemon/ipc/proto_serializer.h>
|
|
#include <yams/daemon/ipc/response_of.hpp>
|
|
|
|
#ifdef _WIN32
|
|
#include <winsock2.h>
|
|
#else
|
|
#include <arpa/inet.h>
|
|
#endif
|
|
|
|
using namespace yams;
|
|
using namespace yams::daemon;
|
|
|
|
namespace {
|
|
// Helper to create messages with Request/Response payloads
|
|
Message makeMessageWith(Request r, uint64_t id = 42) {
|
|
Message m;
|
|
m.requestId = id;
|
|
m.payload = std::move(r);
|
|
return m;
|
|
}
|
|
|
|
Message makeMessageWith(Response r, uint64_t id = 43) {
|
|
Message m;
|
|
m.requestId = id;
|
|
m.payload = std::move(r);
|
|
return m;
|
|
}
|
|
|
|
std::string invalidUtf8(std::string_view prefix, std::string_view suffix = {}) {
|
|
std::string value(prefix);
|
|
value.push_back(static_cast<char>(0xFF));
|
|
value.append(suffix);
|
|
return value;
|
|
}
|
|
|
|
void checkSanitizedField(std::string_view fieldName, const std::string& actual,
|
|
const std::string& original) {
|
|
INFO(fieldName);
|
|
CHECK(actual == yams::common::sanitizeUtf8(original));
|
|
}
|
|
|
|
uint32_t crc32Reference(const std::vector<uint8_t>& data) {
|
|
uint32_t crc = 0xFFFFFFFF;
|
|
for (auto byte : data) {
|
|
crc ^= byte;
|
|
for (int j = 0; j < 8; ++j) {
|
|
crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320u : 0u);
|
|
}
|
|
}
|
|
return ~crc;
|
|
}
|
|
|
|
std::vector<uint8_t> makeRawFrame(const std::vector<uint8_t>& payload, uint32_t flags = 0) {
|
|
MessageFramer::FrameHeader header;
|
|
header.payload_size = static_cast<uint32_t>(payload.size());
|
|
header.checksum = crc32Reference(payload);
|
|
header.flags = flags;
|
|
header.to_network();
|
|
|
|
std::vector<uint8_t> frame(sizeof(header) + payload.size());
|
|
std::memcpy(frame.data(), &header, sizeof(header));
|
|
if (!payload.empty()) {
|
|
std::memcpy(frame.data() + sizeof(header), payload.data(), payload.size());
|
|
}
|
|
return frame;
|
|
}
|
|
} // namespace
|
|
|
|
// =============================================================================
|
|
// CRC32 Reference Vector Tests
|
|
// =============================================================================
|
|
|
|
TEST_CASE("CRC32: known reference vectors via frame header", "[daemon][protocol][crc32]") {
|
|
// MessageFramer::calculate_crc32 is private, so we verify CRC32 values
|
|
// by building raw frames and extracting the checksum from the header.
|
|
// This tests the same code path that will be optimized.
|
|
|
|
auto extract_checksum = [](const std::vector<uint8_t>& payload) -> uint32_t {
|
|
// Build a frame header manually using MessageFramer::frame_message,
|
|
// then extract the CRC32 from the header's checksum field.
|
|
// We construct a raw header to compute the CRC, replicating the algorithm.
|
|
MessageFramer framer;
|
|
|
|
// Use the template frame() which computes CRC32 on the payload
|
|
// We need a FramedMessage type. Instead, build a raw frame manually:
|
|
MessageFramer::FrameHeader header;
|
|
header.payload_size = static_cast<uint32_t>(payload.size());
|
|
// The checksum field is what we want. We can't call calculate_crc32 directly,
|
|
// but we can replicate the ISO CRC32 reference implementation here for validation.
|
|
// This is the reference: NOT the code under test.
|
|
uint32_t crc = 0xFFFFFFFF;
|
|
for (auto byte : payload) {
|
|
crc = crc ^ byte;
|
|
for (int j = 0; j < 8; ++j) {
|
|
crc = (crc >> 1) ^ ((crc & 1) ? 0xEDB88320u : 0u);
|
|
}
|
|
}
|
|
return ~crc;
|
|
};
|
|
|
|
SECTION("Standard check value: \"123456789\" -> 0xCBF43926") {
|
|
std::string input = "123456789";
|
|
std::vector<uint8_t> data(input.begin(), input.end());
|
|
REQUIRE(extract_checksum(data) == 0xCBF43926);
|
|
}
|
|
|
|
SECTION("Empty input -> 0x00000000") {
|
|
std::vector<uint8_t> empty;
|
|
REQUIRE(extract_checksum(empty) == 0x00000000);
|
|
}
|
|
|
|
SECTION("Single zero byte -> 0xD202EF8D") {
|
|
std::vector<uint8_t> data = {0x00};
|
|
REQUIRE(extract_checksum(data) == 0xD202EF8D);
|
|
}
|
|
|
|
// Now verify that frames built by MessageFramer embed the correct CRC32
|
|
// by parsing them back and confirming no checksum mismatch
|
|
SECTION("Frame roundtrip preserves CRC32 for known payloads") {
|
|
MessageFramer framer;
|
|
|
|
// Build a message with known content
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 1;
|
|
msg.payload = PingRequest{};
|
|
|
|
auto framedResult = framer.frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
// Parse it back — this validates CRC32 internally
|
|
auto parsedResult = framer.parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
// Flip one bit in payload, should fail CRC
|
|
auto corrupted = framedResult.value();
|
|
if (corrupted.size() > MessageFramer::HEADER_SIZE + 1) {
|
|
corrupted[MessageFramer::HEADER_SIZE + 1] ^= 0x01;
|
|
auto badResult = framer.parse_frame(corrupted);
|
|
REQUIRE_FALSE(badResult);
|
|
}
|
|
}
|
|
|
|
SECTION("CRC32 determinism: same payload always same frame") {
|
|
MessageFramer framer;
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 42;
|
|
msg.payload = StatusRequest{true};
|
|
|
|
auto frame1 = framer.frame_message(msg);
|
|
auto frame2 = framer.frame_message(msg);
|
|
REQUIRE(frame1);
|
|
REQUIRE(frame2);
|
|
REQUIRE(frame1.value() == frame2.value());
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Message Framing Tests
|
|
// =============================================================================
|
|
|
|
TEST_CASE("MessageFramer: Basic frame creation and parsing", "[daemon][protocol][framing]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
SECTION("Round-trip with PingRequest") {
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 12345;
|
|
msg.timestamp = std::chrono::steady_clock::now();
|
|
msg.payload = PingRequest{};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
REQUIRE(framedResult.value().size() > sizeof(MessageFramer::FrameHeader));
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
REQUIRE(parsedResult.value().version == msg.version);
|
|
REQUIRE(parsedResult.value().requestId == msg.requestId);
|
|
REQUIRE(std::holds_alternative<Request>(parsedResult.value().payload));
|
|
}
|
|
|
|
SECTION("Empty payload handling") {
|
|
Message msg{PROTOCOL_VERSION, 111, std::chrono::steady_clock::now(), PingRequest{}};
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
REQUIRE(parsedResult.value().requestId == 111);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: CRC32 validation", "[daemon][protocol][framing][validation]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 54321;
|
|
msg.payload = StatusRequest{true};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto framedData = framedResult.value();
|
|
|
|
// Corrupt a byte in the payload (after header)
|
|
if (framedData.size() > sizeof(MessageFramer::FrameHeader) + 10) {
|
|
framedData[sizeof(MessageFramer::FrameHeader) + 5] ^= 0xFF;
|
|
}
|
|
|
|
// Parsing should fail due to CRC mismatch
|
|
auto parsedResult = framer->parse_frame(framedData);
|
|
REQUIRE_FALSE(parsedResult);
|
|
REQUIRE(parsedResult.error().code == ErrorCode::InvalidData);
|
|
}
|
|
|
|
TEST_CASE("ErrorResponse retry info round-trips", "[daemon][protocol][error]") {
|
|
ErrorResponse original;
|
|
original.code = ErrorCode::ResourceExhausted;
|
|
original.message = "server busy";
|
|
original.retry = ErrorResponse::RetryInfo{250, "overload"};
|
|
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 77;
|
|
msg.payload = Response{original};
|
|
|
|
MessageFramer framer;
|
|
auto framed = framer.frame_message(msg);
|
|
REQUIRE(framed);
|
|
|
|
auto parsed = framer.parse_frame(framed.value());
|
|
REQUIRE(parsed);
|
|
REQUIRE(std::holds_alternative<Response>(parsed.value().payload));
|
|
const auto& response = std::get<Response>(parsed.value().payload);
|
|
REQUIRE(std::holds_alternative<ErrorResponse>(response));
|
|
const auto& error = std::get<ErrorResponse>(response);
|
|
REQUIRE(error.code == ErrorCode::ResourceExhausted);
|
|
REQUIRE(error.message == "server busy");
|
|
REQUIRE(error.retry.has_value());
|
|
CHECK(error.retry->retryAfterMs == 250);
|
|
CHECK(error.retry->reason == "overload");
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: Large message handling", "[daemon][protocol][framing][size]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
SECTION("Large query string (10KB)") {
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 99999;
|
|
|
|
SearchRequest req;
|
|
req.query = std::string(10000, 'x');
|
|
req.limit = 100;
|
|
msg.payload = req;
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
REQUIRE(framedResult.value().size() > 10000);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* parsedReq =
|
|
std::get_if<SearchRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(parsedReq != nullptr);
|
|
REQUIRE(parsedReq->query.size() == 10000);
|
|
REQUIRE(parsedReq->query == std::string(10000, 'x'));
|
|
}
|
|
|
|
SECTION("Message size limits") {
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 222;
|
|
|
|
SearchRequest req;
|
|
req.query = std::string(MAX_MESSAGE_SIZE / 2, 'y'); // Under limit
|
|
msg.payload = req;
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
// Oversized message should fail
|
|
req.query = std::string(MAX_MESSAGE_SIZE + 1000, 'z');
|
|
msg.payload = req;
|
|
|
|
framedResult = framer->frame_message(msg);
|
|
REQUIRE_FALSE(framedResult);
|
|
REQUIRE(framedResult.error().code == ErrorCode::InvalidData);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: SearchRequest sanitizes invalid UTF-8 for protobuf",
|
|
"[daemon][protocol][framing][utf8]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
SearchRequest req;
|
|
req.query = std::string("task=performance owner=opencode");
|
|
req.query.push_back(static_cast<char>(0xFF));
|
|
req.query.append(" tail");
|
|
req.searchType = "keyword";
|
|
req.hashQuery = std::string("\xFEhash", 5);
|
|
req.pathPattern = std::string("\xFF*.md", 5);
|
|
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 444;
|
|
msg.payload = req;
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* parsedReq = std::get_if<SearchRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(parsedReq != nullptr);
|
|
REQUIRE(parsedReq->query == yams::common::sanitizeUtf8(req.query));
|
|
REQUIRE(parsedReq->hashQuery == yams::common::sanitizeUtf8(req.hashQuery));
|
|
REQUIRE(parsedReq->pathPattern == yams::common::sanitizeUtf8(req.pathPattern));
|
|
REQUIRE(parsedReq->searchType == "keyword");
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: Header validation", "[daemon][protocol][framing][validation]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
SECTION("Invalid magic number") {
|
|
std::vector<uint8_t> badFrame(sizeof(MessageFramer::FrameHeader) + 100);
|
|
auto* header = reinterpret_cast<MessageFramer::FrameHeader*>(badFrame.data());
|
|
header->magic = htonl(0xDEADBEEF); // Wrong magic
|
|
header->version = htonl(MessageFramer::FRAME_VERSION);
|
|
header->payload_size = htonl(100);
|
|
header->checksum = htonl(0);
|
|
|
|
auto result = framer->parse_frame(badFrame);
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == ErrorCode::InvalidData);
|
|
}
|
|
|
|
SECTION("Version mismatch") {
|
|
std::vector<uint8_t> badFrame(sizeof(MessageFramer::FrameHeader) + 100);
|
|
auto* header = reinterpret_cast<MessageFramer::FrameHeader*>(badFrame.data());
|
|
header->magic = htonl(MessageFramer::FRAME_MAGIC);
|
|
header->version = htonl(99); // Wrong version
|
|
header->payload_size = htonl(100);
|
|
header->checksum = htonl(0);
|
|
|
|
auto result = framer->parse_frame(badFrame);
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == ErrorCode::InvalidData);
|
|
}
|
|
|
|
SECTION("Partial frame data") {
|
|
Message msg{PROTOCOL_VERSION, 333, std::chrono::steady_clock::now(), ShutdownRequest{true}};
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto& fullFrame = framedResult.value();
|
|
if (fullFrame.size() > 10) {
|
|
std::vector<uint8_t> partialFrame(fullFrame.begin(),
|
|
fullFrame.begin() + fullFrame.size() / 2);
|
|
auto result = framer->parse_frame(partialFrame);
|
|
REQUIRE_FALSE(result);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: valid frame rejects malformed protobuf envelope",
|
|
"[daemon][protocol][framing][validation]") {
|
|
MessageFramer framer;
|
|
|
|
// 0x80 is a truncated protobuf varint: framing and CRC can be valid while
|
|
// Envelope parsing still fails.
|
|
const std::vector<uint8_t> malformed_payload = {0x80};
|
|
auto frame = makeRawFrame(malformed_payload);
|
|
|
|
auto result = framer.parse_frame(frame);
|
|
REQUIRE_FALSE(result);
|
|
REQUIRE(result.error().code == ErrorCode::SerializationError);
|
|
CHECK(result.error().message.find("Failed to parse protobuf Envelope") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: Concurrency and thread safety",
|
|
"[daemon][protocol][framing][concurrency]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
const int numThreads = 10;
|
|
const int messagesPerThread = 100;
|
|
std::atomic<int> successCount{0};
|
|
std::atomic<int> failCount{0};
|
|
|
|
std::vector<std::thread> threads;
|
|
for (int t = 0; t < numThreads; ++t) {
|
|
threads.emplace_back([&framer, t, &successCount, &failCount]() {
|
|
for (int i = 0; i < messagesPerThread; ++i) {
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = t * 1000 + i;
|
|
msg.payload = StatusRequest{i % 2 == 0};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
if (framedResult) {
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
if (parsedResult && parsedResult.value().requestId == msg.requestId) {
|
|
successCount++;
|
|
} else {
|
|
failCount++;
|
|
}
|
|
} else {
|
|
failCount++;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
for (auto& t : threads) {
|
|
t.join();
|
|
}
|
|
|
|
REQUIRE(successCount == numThreads * messagesPerThread);
|
|
REQUIRE(failCount == 0);
|
|
}
|
|
|
|
TEST_CASE("MessageFramer: All message types", "[daemon][protocol][framing][types]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
struct TestCase {
|
|
std::string name;
|
|
std::variant<Request, Response> payload;
|
|
};
|
|
|
|
auto testCase = GENERATE(table<std::string, std::variant<Request, Response>>({
|
|
{"PingRequest", std::variant<Request, Response>{PingRequest{}}},
|
|
{"StatusRequest", std::variant<Request, Response>{StatusRequest{true}}},
|
|
{"ShutdownRequest", std::variant<Request, Response>{ShutdownRequest{false}}},
|
|
{"SearchRequest", std::variant<Request, Response>{SearchRequest{
|
|
"test", 10, false, false, 0.7, {}, "keyword"}}},
|
|
{"GenerateEmbeddingRequest",
|
|
std::variant<Request, Response>{GenerateEmbeddingRequest{"text", "model"}}},
|
|
{"LoadModelRequest", std::variant<Request, Response>{LoadModelRequest{"test-model"}}},
|
|
{"PongResponse", std::variant<Request, Response>{PongResponse{
|
|
std::chrono::steady_clock::now(), std::chrono::milliseconds{0}}}},
|
|
}));
|
|
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 444;
|
|
msg.payload = std::get<1>(testCase);
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
REQUIRE(parsedResult.value().requestId == 444);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Protocol Serialization Tests (DownloadRequest/Response)
|
|
// =============================================================================
|
|
|
|
TEST_CASE("DownloadProtocol: Request serialization", "[daemon][protocol][download]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
SECTION("Full DownloadRequest roundtrip") {
|
|
Message msg;
|
|
msg.version = PROTOCOL_VERSION;
|
|
msg.requestId = 100;
|
|
msg.timestamp = std::chrono::steady_clock::now();
|
|
|
|
DownloadRequest req;
|
|
req.url = "https://example.com/file.txt";
|
|
req.outputPath = "/tmp/output.txt";
|
|
req.checksum = "sha256:abcdef0123456789";
|
|
req.tags = {"tag1", "tag2", "tag3"};
|
|
req.metadata = {{"key1", "value1"}, {"key2", "value2"}};
|
|
req.quiet = true;
|
|
msg.payload = Request{req};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto& parsedMsg = parsedResult.value();
|
|
REQUIRE(std::holds_alternative<Request>(parsedMsg.payload));
|
|
auto* downloadReq = std::get_if<DownloadRequest>(&std::get<Request>(parsedMsg.payload));
|
|
REQUIRE(downloadReq != nullptr);
|
|
REQUIRE(downloadReq->url == req.url);
|
|
REQUIRE(downloadReq->outputPath == req.outputPath);
|
|
REQUIRE(downloadReq->checksum == req.checksum);
|
|
REQUIRE(downloadReq->tags == req.tags);
|
|
REQUIRE(downloadReq->metadata == req.metadata);
|
|
REQUIRE(downloadReq->quiet == req.quiet);
|
|
}
|
|
|
|
SECTION("Special characters and Unicode") {
|
|
Message msg{PROTOCOL_VERSION, 104, std::chrono::steady_clock::now(), Request{}};
|
|
DownloadRequest req;
|
|
req.url = "https://example.com/file with spaces & special=chars?param=value#anchor";
|
|
req.outputPath = "/path/with spaces/and-特殊文字/file.txt";
|
|
req.checksum = "sha512:ABCDEF123456";
|
|
req.tags = {"tag with spaces", "tag/with/slashes", "tag&special"};
|
|
req.metadata = {{"key with spaces", "value\nwith\nnewlines"}, {"特殊", "文字"}};
|
|
msg.payload = Request{req};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* downloadReq =
|
|
std::get_if<DownloadRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(downloadReq != nullptr);
|
|
REQUIRE(downloadReq->url == req.url);
|
|
REQUIRE(downloadReq->outputPath == req.outputPath);
|
|
REQUIRE(downloadReq->checksum == req.checksum);
|
|
REQUIRE(downloadReq->tags == req.tags);
|
|
REQUIRE(downloadReq->metadata == req.metadata);
|
|
}
|
|
|
|
SECTION("Large metadata and tags") {
|
|
Message msg{PROTOCOL_VERSION, 105, std::chrono::steady_clock::now(), Request{}};
|
|
DownloadRequest req;
|
|
req.url = "https://example.com/large";
|
|
|
|
for (int i = 0; i < 100; ++i) {
|
|
req.metadata["key" + std::to_string(i)] = "value" + std::to_string(i);
|
|
}
|
|
for (int i = 0; i < 50; ++i) {
|
|
req.tags.push_back("tag" + std::to_string(i));
|
|
}
|
|
msg.payload = Request{req};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* downloadReq =
|
|
std::get_if<DownloadRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(downloadReq != nullptr);
|
|
REQUIRE(downloadReq->metadata.size() == 100);
|
|
REQUIRE(downloadReq->tags.size() == 50);
|
|
}
|
|
|
|
SECTION("DownloadStatusRequest roundtrip") {
|
|
Message msg{PROTOCOL_VERSION, 106, std::chrono::steady_clock::now(), Request{}};
|
|
DownloadStatusRequest req;
|
|
req.jobId = "download-17";
|
|
msg.payload = Request{req};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* statusReq =
|
|
std::get_if<DownloadStatusRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(statusReq != nullptr);
|
|
REQUIRE(statusReq->jobId == req.jobId);
|
|
}
|
|
|
|
SECTION("CancelDownloadJobRequest roundtrip") {
|
|
Message msg{PROTOCOL_VERSION, 106, std::chrono::steady_clock::now(), Request{}};
|
|
CancelDownloadJobRequest req;
|
|
req.jobId = "download-18";
|
|
msg.payload = Request{req};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* cancelReq =
|
|
std::get_if<CancelDownloadJobRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(cancelReq != nullptr);
|
|
REQUIRE(cancelReq->jobId == req.jobId);
|
|
}
|
|
|
|
SECTION("ListDownloadJobsRequest roundtrip") {
|
|
Message msg{PROTOCOL_VERSION, 107, std::chrono::steady_clock::now(), Request{}};
|
|
ListDownloadJobsRequest req;
|
|
req.limit = 7;
|
|
msg.payload = Request{req};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* listReq =
|
|
std::get_if<ListDownloadJobsRequest>(&std::get<Request>(parsedResult.value().payload));
|
|
REQUIRE(listReq != nullptr);
|
|
REQUIRE(listReq->limit == req.limit);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("DownloadProtocol: Response serialization", "[daemon][protocol][download]") {
|
|
auto framer = std::make_unique<MessageFramer>();
|
|
|
|
SECTION("Success response") {
|
|
Message msg{PROTOCOL_VERSION, 101, std::chrono::steady_clock::now(), Response{}};
|
|
DownloadResponse res;
|
|
res.hash = "sha256:abcdef1234567890";
|
|
res.localPath = "/tmp/downloaded_file.txt";
|
|
res.url = "https://example.com/file.txt";
|
|
res.jobId = "download-42";
|
|
res.state = "completed";
|
|
res.createdAtMs = 1111;
|
|
res.updatedAtMs = 2222;
|
|
res.size = 12345;
|
|
res.success = true;
|
|
res.error = "";
|
|
msg.payload = Response{res};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* downloadRes =
|
|
std::get_if<DownloadResponse>(&std::get<Response>(parsedResult.value().payload));
|
|
REQUIRE(downloadRes != nullptr);
|
|
REQUIRE(downloadRes->hash == res.hash);
|
|
REQUIRE(downloadRes->localPath == res.localPath);
|
|
REQUIRE(downloadRes->url == res.url);
|
|
REQUIRE(downloadRes->jobId == res.jobId);
|
|
REQUIRE(downloadRes->state == res.state);
|
|
REQUIRE(downloadRes->createdAtMs == res.createdAtMs);
|
|
REQUIRE(downloadRes->updatedAtMs == res.updatedAtMs);
|
|
REQUIRE(downloadRes->size == res.size);
|
|
REQUIRE(downloadRes->success == true);
|
|
}
|
|
|
|
SECTION("Error response") {
|
|
Message msg{PROTOCOL_VERSION, 102, std::chrono::steady_clock::now(), Response{}};
|
|
DownloadResponse res;
|
|
res.hash = "";
|
|
res.localPath = "";
|
|
res.url = "https://example.com/nonexistent.txt";
|
|
res.jobId = "download-43";
|
|
res.state = "failed";
|
|
res.createdAtMs = 3333;
|
|
res.updatedAtMs = 4444;
|
|
res.size = 0;
|
|
res.success = false;
|
|
res.error = "404 Not Found";
|
|
msg.payload = Response{res};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* downloadRes =
|
|
std::get_if<DownloadResponse>(&std::get<Response>(parsedResult.value().payload));
|
|
REQUIRE(downloadRes != nullptr);
|
|
REQUIRE_FALSE(downloadRes->success);
|
|
REQUIRE(downloadRes->error == "404 Not Found");
|
|
REQUIRE(downloadRes->size == 0);
|
|
}
|
|
|
|
SECTION("ListDownloadJobsResponse roundtrip") {
|
|
Message msg{PROTOCOL_VERSION, 103, std::chrono::steady_clock::now(), Response{}};
|
|
ListDownloadJobsResponse res;
|
|
DownloadResponse first;
|
|
first.url = "https://example.com/a";
|
|
first.jobId = "download-1";
|
|
first.state = "completed";
|
|
first.createdAtMs = 100;
|
|
first.updatedAtMs = 200;
|
|
first.success = true;
|
|
DownloadResponse second;
|
|
second.url = "https://example.com/b";
|
|
second.jobId = "download-2";
|
|
second.state = "failed";
|
|
second.createdAtMs = 300;
|
|
second.updatedAtMs = 400;
|
|
second.success = false;
|
|
second.error = "boom";
|
|
res.jobs = {first, second};
|
|
msg.payload = Response{res};
|
|
|
|
auto framedResult = framer->frame_message(msg);
|
|
REQUIRE(framedResult);
|
|
|
|
auto parsedResult = framer->parse_frame(framedResult.value());
|
|
REQUIRE(parsedResult);
|
|
|
|
auto* listRes = std::get_if<ListDownloadJobsResponse>(
|
|
&std::get<Response>(parsedResult.value().payload));
|
|
REQUIRE(listRes != nullptr);
|
|
REQUIRE(listRes->jobs.size() == 2);
|
|
REQUIRE(listRes->jobs.at(0).jobId == "download-1");
|
|
REQUIRE(listRes->jobs.at(1).error == "boom");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// ProtoSerializer Roundtrip Tests
|
|
// =============================================================================
|
|
|
|
TEST_CASE("ProtoSerializer: Request roundtrip", "[daemon][protocol][serialization]") {
|
|
SECTION("CatRequest") {
|
|
CatRequest cr;
|
|
cr.hash = "";
|
|
cr.name = "inline.txt";
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{cr}, 1));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<CatRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->name == "inline.txt");
|
|
}
|
|
|
|
SECTION("ListSessionsRequest") {
|
|
auto enc =
|
|
ProtoSerializer::encode_payload(makeMessageWith(Request{ListSessionsRequest{}}, 2));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto req = std::get<Request>(dec.value().payload);
|
|
REQUIRE(std::holds_alternative<ListSessionsRequest>(req));
|
|
}
|
|
|
|
SECTION("SearchRequest carries session") {
|
|
SearchRequest sr;
|
|
sr.query = "hello";
|
|
sr.useSession = true;
|
|
sr.sessionName = "feature-auth";
|
|
sr.instanceId = "inst-abc-123";
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{sr}, 10));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<SearchRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->useSession);
|
|
REQUIRE(got->sessionName == "feature-auth");
|
|
REQUIRE(got->instanceId == "inst-abc-123");
|
|
}
|
|
|
|
SECTION("GrepRequest carries session") {
|
|
GrepRequest gr;
|
|
gr.pattern = "TODO";
|
|
gr.useSession = true;
|
|
gr.sessionName = "feature-auth";
|
|
gr.instanceId = "inst-grep-456";
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{gr}, 11));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<GrepRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->useSession);
|
|
REQUIRE(got->sessionName == "feature-auth");
|
|
REQUIRE(got->instanceId == "inst-grep-456");
|
|
}
|
|
|
|
SECTION("AddDocumentRequest carries session") {
|
|
AddDocumentRequest ar;
|
|
ar.path = "/tmp/a.txt";
|
|
ar.sessionId = "feature-auth";
|
|
ar.instanceId = "inst-add-789";
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{ar}, 12));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<AddDocumentRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->sessionId == "feature-auth");
|
|
REQUIRE(got->instanceId == "inst-add-789");
|
|
}
|
|
|
|
SECTION("ListRequest carries session") {
|
|
ListRequest lr;
|
|
lr.limit = 5;
|
|
lr.sessionId = "feature-auth";
|
|
lr.instanceId = "inst-list-012";
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{lr}, 13));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<ListRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->sessionId == "feature-auth");
|
|
REQUIRE(got->instanceId == "inst-list-012");
|
|
}
|
|
|
|
SECTION("BatchEmbeddingRequest sanitizes invalid UTF-8") {
|
|
BatchEmbeddingRequest req;
|
|
req.texts = {"hello", std::string("\xFF\xFE", 2) + "broken"};
|
|
req.modelName = "all-MiniLM-L6-v2";
|
|
req.normalize = true;
|
|
req.batchSize = 8;
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 14));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Request>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<BatchEmbeddingRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->texts.size() == req.texts.size());
|
|
CHECK(got->texts[0] == yams::common::sanitizeUtf8(req.texts[0]));
|
|
CHECK(got->texts[1] == yams::common::sanitizeUtf8(req.texts[1]));
|
|
}
|
|
|
|
SECTION("GetRequest sanitizes invalid UTF-8 text fields") {
|
|
GetRequest req;
|
|
req.hash = invalidUtf8("sha256:");
|
|
req.name = invalidUtf8("notes", ".md");
|
|
req.fileType = invalidUtf8("text");
|
|
req.mimeType = invalidUtf8("text/plain");
|
|
req.extension = invalidUtf8(".md");
|
|
req.createdAfter = invalidUtf8("2025-01-01");
|
|
req.modifiedBefore = invalidUtf8("2025-12-31");
|
|
req.indexedAfter = invalidUtf8("yesterday");
|
|
req.outputPath = invalidUtf8("/tmp/out", ".txt");
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 15));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<GetRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
checkSanitizedField("GetRequest.hash", got->hash, req.hash);
|
|
checkSanitizedField("GetRequest.name", got->name, req.name);
|
|
checkSanitizedField("GetRequest.fileType", got->fileType, req.fileType);
|
|
checkSanitizedField("GetRequest.mimeType", got->mimeType, req.mimeType);
|
|
checkSanitizedField("GetRequest.extension", got->extension, req.extension);
|
|
checkSanitizedField("GetRequest.createdAfter", got->createdAfter, req.createdAfter);
|
|
checkSanitizedField("GetRequest.modifiedBefore", got->modifiedBefore, req.modifiedBefore);
|
|
checkSanitizedField("GetRequest.indexedAfter", got->indexedAfter, req.indexedAfter);
|
|
checkSanitizedField("GetRequest.outputPath", got->outputPath, req.outputPath);
|
|
}
|
|
|
|
SECTION("GetRequest preserves content selection flags") {
|
|
GetRequest req;
|
|
req.hash = "sha256:abc123";
|
|
req.metadataOnly = false;
|
|
req.includeContent = false;
|
|
req.maxBytes = 4096;
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 16));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<GetRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
CHECK(got->hash == req.hash);
|
|
CHECK(got->metadataOnly == req.metadataOnly);
|
|
CHECK(got->includeContent == req.includeContent);
|
|
CHECK(got->maxBytes == req.maxBytes);
|
|
}
|
|
|
|
SECTION("UpdateDocumentResponse preserves detailed update fields") {
|
|
UpdateDocumentResponse resp;
|
|
resp.hash = "sha256:update123";
|
|
resp.contentUpdated = false;
|
|
resp.metadataUpdated = true;
|
|
resp.tagsUpdated = true;
|
|
resp.updatesApplied = 2;
|
|
resp.tagsAdded = 1;
|
|
resp.tagsRemoved = 1;
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 16));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<UpdateDocumentResponse>(&std::get<Response>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
CHECK(got->hash == resp.hash);
|
|
CHECK(got->contentUpdated == resp.contentUpdated);
|
|
CHECK(got->metadataUpdated == resp.metadataUpdated);
|
|
CHECK(got->tagsUpdated == resp.tagsUpdated);
|
|
CHECK(got->updatesApplied == resp.updatesApplied);
|
|
CHECK(got->tagsAdded == resp.tagsAdded);
|
|
CHECK(got->tagsRemoved == resp.tagsRemoved);
|
|
}
|
|
|
|
SECTION("ListRequest sanitizes invalid UTF-8 filter and session fields") {
|
|
ListRequest req;
|
|
req.format = invalidUtf8("table");
|
|
req.sortBy = invalidUtf8("date");
|
|
req.fileType = invalidUtf8("text");
|
|
req.mimeType = invalidUtf8("text/plain");
|
|
req.extensions = invalidUtf8("md,txt");
|
|
req.createdAfter = invalidUtf8("2025-01-01");
|
|
req.changeWindow = invalidUtf8("24h");
|
|
req.tags = {invalidUtf8("task=perf"), "owner=opencode"};
|
|
req.filterTags = invalidUtf8("phase=checkpoint");
|
|
req.namePattern = invalidUtf8("*.md");
|
|
req.metadataFilters = {{invalidUtf8("task"), invalidUtf8("performance")},
|
|
{"owner", invalidUtf8("opencode")}};
|
|
req.sessionId = invalidUtf8("feature-auth");
|
|
req.instanceId = invalidUtf8("inst-list");
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 16));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<ListRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
checkSanitizedField("ListRequest.format", got->format, req.format);
|
|
checkSanitizedField("ListRequest.sortBy", got->sortBy, req.sortBy);
|
|
checkSanitizedField("ListRequest.fileType", got->fileType, req.fileType);
|
|
checkSanitizedField("ListRequest.mimeType", got->mimeType, req.mimeType);
|
|
checkSanitizedField("ListRequest.extensions", got->extensions, req.extensions);
|
|
checkSanitizedField("ListRequest.createdAfter", got->createdAfter, req.createdAfter);
|
|
checkSanitizedField("ListRequest.changeWindow", got->changeWindow, req.changeWindow);
|
|
REQUIRE(got->tags.size() == req.tags.size());
|
|
checkSanitizedField("ListRequest.tags[0]", got->tags[0], req.tags[0]);
|
|
checkSanitizedField("ListRequest.filterTags", got->filterTags, req.filterTags);
|
|
checkSanitizedField("ListRequest.namePattern", got->namePattern, req.namePattern);
|
|
checkSanitizedField(
|
|
"ListRequest.metadataFilters.task",
|
|
got->metadataFilters.at(yams::common::sanitizeUtf8(req.metadataFilters.begin()->first)),
|
|
req.metadataFilters.begin()->second);
|
|
checkSanitizedField("ListRequest.sessionId", got->sessionId, req.sessionId);
|
|
checkSanitizedField("ListRequest.instanceId", got->instanceId, req.instanceId);
|
|
}
|
|
|
|
SECTION("AddDocumentRequest sanitizes invalid UTF-8 metadata and routing fields") {
|
|
AddDocumentRequest req;
|
|
req.path = invalidUtf8("/tmp/doc", ".md");
|
|
req.name = invalidUtf8("doc", ".md");
|
|
req.tags = {invalidUtf8("tag-a"), "tag-b"};
|
|
req.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}};
|
|
req.includePatterns = {invalidUtf8("*.md")};
|
|
req.excludePatterns = {invalidUtf8("*.bin")};
|
|
req.collection = invalidUtf8("notes");
|
|
req.snapshotId = invalidUtf8("snap-123");
|
|
req.snapshotLabel = invalidUtf8("checkpoint");
|
|
req.sessionId = invalidUtf8("session-a");
|
|
req.instanceId = invalidUtf8("instance-a");
|
|
req.mimeType = invalidUtf8("text/plain");
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 17));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<AddDocumentRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
checkSanitizedField("AddDocumentRequest.path", got->path, req.path);
|
|
checkSanitizedField("AddDocumentRequest.name", got->name, req.name);
|
|
checkSanitizedField("AddDocumentRequest.tags[0]", got->tags.at(0), req.tags.at(0));
|
|
checkSanitizedField("AddDocumentRequest.collection", got->collection, req.collection);
|
|
checkSanitizedField("AddDocumentRequest.snapshotId", got->snapshotId, req.snapshotId);
|
|
checkSanitizedField("AddDocumentRequest.snapshotLabel", got->snapshotLabel,
|
|
req.snapshotLabel);
|
|
checkSanitizedField("AddDocumentRequest.sessionId", got->sessionId, req.sessionId);
|
|
checkSanitizedField("AddDocumentRequest.instanceId", got->instanceId, req.instanceId);
|
|
checkSanitizedField("AddDocumentRequest.mimeType", got->mimeType, req.mimeType);
|
|
}
|
|
|
|
SECTION("GrepRequest sanitizes invalid UTF-8 filter and session fields") {
|
|
GrepRequest req;
|
|
req.pattern = invalidUtf8("task=performance");
|
|
req.path = invalidUtf8("/tmp");
|
|
req.paths = {invalidUtf8("/repo/src")};
|
|
req.includePatterns = {invalidUtf8("*.cpp")};
|
|
req.filterTags = {invalidUtf8("owner=opencode")};
|
|
req.colorMode = invalidUtf8("auto");
|
|
req.useSession = true;
|
|
req.sessionName = invalidUtf8("session-grep");
|
|
req.instanceId = invalidUtf8("instance-grep");
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 18));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<GrepRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
checkSanitizedField("GrepRequest.pattern", got->pattern, req.pattern);
|
|
checkSanitizedField("GrepRequest.path", got->path, req.path);
|
|
checkSanitizedField("GrepRequest.paths[0]", got->paths.at(0), req.paths.at(0));
|
|
checkSanitizedField("GrepRequest.includePatterns[0]", got->includePatterns.at(0),
|
|
req.includePatterns.at(0));
|
|
checkSanitizedField("GrepRequest.filterTags[0]", got->filterTags.at(0),
|
|
req.filterTags.at(0));
|
|
checkSanitizedField("GrepRequest.colorMode", got->colorMode, req.colorMode);
|
|
checkSanitizedField("GrepRequest.sessionName", got->sessionName, req.sessionName);
|
|
checkSanitizedField("GrepRequest.instanceId", got->instanceId, req.instanceId);
|
|
}
|
|
|
|
SECTION("DownloadRequest sanitizes invalid UTF-8 text fields") {
|
|
DownloadRequest req;
|
|
req.url = invalidUtf8("https://example.com/file");
|
|
req.outputPath = invalidUtf8("/tmp/download", ".txt");
|
|
req.checksum = invalidUtf8("sha256:", "deadbeef");
|
|
req.tags = {invalidUtf8("tag-download")};
|
|
req.metadata = {{invalidUtf8("source"), invalidUtf8("cli")}};
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 19));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<DownloadRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
checkSanitizedField("DownloadRequest.url", got->url, req.url);
|
|
checkSanitizedField("DownloadRequest.outputPath", got->outputPath, req.outputPath);
|
|
checkSanitizedField("DownloadRequest.checksum", got->checksum, req.checksum);
|
|
checkSanitizedField("DownloadRequest.tags[0]", got->tags.at(0), req.tags.at(0));
|
|
}
|
|
|
|
SECTION("UpdateDocumentRequest sanitizes invalid UTF-8 text fields") {
|
|
UpdateDocumentRequest req;
|
|
req.hash = invalidUtf8("sha256:");
|
|
req.name = invalidUtf8("doc", ".md");
|
|
req.newContent = invalidUtf8("updated content");
|
|
req.addTags = {invalidUtf8("tag-add")};
|
|
req.removeTags = {invalidUtf8("tag-remove")};
|
|
req.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}};
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Request{req}, 23));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
auto* got = std::get_if<UpdateDocumentRequest>(&std::get<Request>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
checkSanitizedField("UpdateDocumentRequest.hash", got->hash, req.hash);
|
|
checkSanitizedField("UpdateDocumentRequest.name", got->name, req.name);
|
|
checkSanitizedField("UpdateDocumentRequest.newContent", got->newContent, req.newContent);
|
|
checkSanitizedField("UpdateDocumentRequest.addTags[0]", got->addTags.at(0),
|
|
req.addTags.at(0));
|
|
checkSanitizedField("UpdateDocumentRequest.removeTags[0]", got->removeTags.at(0),
|
|
req.removeTags.at(0));
|
|
}
|
|
}
|
|
|
|
TEST_CASE("ProtoSerializer: Response roundtrip", "[daemon][protocol][serialization]") {
|
|
DownloadResponse dr;
|
|
dr.hash = "deadbeef";
|
|
dr.localPath = "/tmp/file";
|
|
dr.url = "https://example.com";
|
|
dr.jobId = "download-9";
|
|
dr.state = "completed";
|
|
dr.createdAtMs = 123;
|
|
dr.updatedAtMs = 456;
|
|
dr.size = 1234;
|
|
dr.success = true;
|
|
dr.error = "";
|
|
|
|
auto enc = ProtoSerializer::encode_payload(makeMessageWith(Response{dr}, 3));
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
REQUIRE(std::holds_alternative<Response>(dec.value().payload));
|
|
|
|
auto* got = std::get_if<DownloadResponse>(&std::get<Response>(dec.value().payload));
|
|
REQUIRE(got != nullptr);
|
|
REQUIRE(got->hash == "deadbeef");
|
|
REQUIRE(got->jobId == "download-9");
|
|
REQUIRE(got->state == "completed");
|
|
REQUIRE(got->createdAtMs == 123);
|
|
REQUIRE(got->updatedAtMs == 456);
|
|
REQUIRE(got->size == 1234);
|
|
REQUIRE(got->success == true);
|
|
|
|
SECTION("GraphQueryResponse includes edges") {
|
|
GraphQueryResponse gr;
|
|
gr.originNode.nodeId = 1;
|
|
gr.originNode.nodeKey = "path:file:/root/a.cpp";
|
|
gr.originNode.label = "a.cpp";
|
|
gr.originNode.type = "file";
|
|
|
|
GraphNode cn;
|
|
cn.nodeId = 2;
|
|
cn.nodeKey = "symbol:foo";
|
|
cn.label = "foo";
|
|
cn.type = "function";
|
|
cn.distance = 1;
|
|
gr.connectedNodes.push_back(cn);
|
|
|
|
GraphEdge edge;
|
|
edge.edgeId = 42;
|
|
edge.srcNodeId = 1;
|
|
edge.dstNodeId = 2;
|
|
edge.relation = "defines";
|
|
edge.weight = 0.9f;
|
|
edge.properties = "{\"source\":\"test\"}";
|
|
gr.edges.push_back(edge);
|
|
|
|
gr.totalNodesFound = 1;
|
|
gr.totalEdgesTraversed = 1;
|
|
gr.kgAvailable = true;
|
|
|
|
auto encGraph = ProtoSerializer::encode_payload(
|
|
makeMessageWith(Response{std::in_place_type<GraphQueryResponse>, gr}, 4));
|
|
REQUIRE(encGraph);
|
|
|
|
auto decGraph = ProtoSerializer::decode_payload(encGraph.value());
|
|
REQUIRE(decGraph);
|
|
REQUIRE(std::holds_alternative<Response>(decGraph.value().payload));
|
|
|
|
auto* gotGraph =
|
|
std::get_if<GraphQueryResponse>(&std::get<Response>(decGraph.value().payload));
|
|
REQUIRE(gotGraph != nullptr);
|
|
REQUIRE(gotGraph->edges.size() == 1);
|
|
CHECK(gotGraph->edges[0].edgeId == 42);
|
|
CHECK(gotGraph->edges[0].relation == "defines");
|
|
CHECK(gotGraph->edges[0].properties == "{\"source\":\"test\"}");
|
|
}
|
|
|
|
SECTION("ErrorResponse sanitizes invalid UTF-8 message") {
|
|
ErrorResponse err;
|
|
err.code = ErrorCode::InvalidData;
|
|
err.message = invalidUtf8("bad-search-query");
|
|
|
|
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{err}, 20));
|
|
REQUIRE(encoded);
|
|
|
|
auto decoded = ProtoSerializer::decode_payload(encoded.value());
|
|
REQUIRE(decoded);
|
|
|
|
auto* decodedError =
|
|
std::get_if<ErrorResponse>(&std::get<Response>(decoded.value().payload));
|
|
REQUIRE(decodedError != nullptr);
|
|
checkSanitizedField("ErrorResponse.message", decodedError->message, err.message);
|
|
}
|
|
|
|
SECTION("RepairEvent sanitizes invalid UTF-8 progress text") {
|
|
RepairEvent ev;
|
|
ev.phase = invalidUtf8("repairing");
|
|
ev.operation = invalidUtf8("fts5");
|
|
ev.message = invalidUtf8("rebuilding-index");
|
|
|
|
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{ev}, 21));
|
|
REQUIRE(encoded);
|
|
|
|
auto decoded = ProtoSerializer::decode_payload(encoded.value());
|
|
REQUIRE(decoded);
|
|
|
|
auto* decodedEvent = std::get_if<RepairEvent>(&std::get<Response>(decoded.value().payload));
|
|
REQUIRE(decodedEvent != nullptr);
|
|
checkSanitizedField("RepairEvent.phase", decodedEvent->phase, ev.phase);
|
|
checkSanitizedField("RepairEvent.operation", decodedEvent->operation, ev.operation);
|
|
checkSanitizedField("RepairEvent.message", decodedEvent->message, ev.message);
|
|
}
|
|
|
|
SECTION("RepairResponse sanitizes invalid UTF-8 errors and operation messages") {
|
|
RepairResponse resp;
|
|
resp.success = false;
|
|
resp.errors = {invalidUtf8("fts5 failed"), invalidUtf8("mime failed")};
|
|
|
|
RepairOperationResult op;
|
|
op.operation = invalidUtf8("embeddings");
|
|
op.message = invalidUtf8("bad utf8 in batch");
|
|
resp.operationResults.push_back(op);
|
|
|
|
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 22));
|
|
REQUIRE(encoded);
|
|
|
|
auto decoded = ProtoSerializer::decode_payload(encoded.value());
|
|
REQUIRE(decoded);
|
|
|
|
auto* decodedResponse =
|
|
std::get_if<RepairResponse>(&std::get<Response>(decoded.value().payload));
|
|
REQUIRE(decodedResponse != nullptr);
|
|
REQUIRE(decodedResponse->errors.size() == resp.errors.size());
|
|
checkSanitizedField("RepairResponse.errors[0]", decodedResponse->errors.at(0),
|
|
resp.errors.at(0));
|
|
REQUIRE(decodedResponse->operationResults.size() == 1);
|
|
checkSanitizedField("RepairResponse.operationResults[0].operation",
|
|
decodedResponse->operationResults.at(0).operation,
|
|
resp.operationResults.at(0).operation);
|
|
checkSanitizedField("RepairResponse.operationResults[0].message",
|
|
decodedResponse->operationResults.at(0).message,
|
|
resp.operationResults.at(0).message);
|
|
}
|
|
|
|
SECTION("GetResponse sanitizes invalid UTF-8 descriptor fields without content") {
|
|
GetResponse resp;
|
|
resp.hash = invalidUtf8("sha256:");
|
|
resp.name = invalidUtf8("doc", ".md");
|
|
resp.path = invalidUtf8("/tmp/doc", ".md");
|
|
resp.hasContent = false;
|
|
resp.metadata = {{invalidUtf8("task"), invalidUtf8("performance")}};
|
|
|
|
auto encoded = ProtoSerializer::encode_payload(makeMessageWith(Response{resp}, 24));
|
|
REQUIRE(encoded);
|
|
|
|
auto decoded = ProtoSerializer::decode_payload(encoded.value());
|
|
REQUIRE(decoded);
|
|
|
|
auto* decodedResponse =
|
|
std::get_if<GetResponse>(&std::get<Response>(decoded.value().payload));
|
|
REQUIRE(decodedResponse != nullptr);
|
|
checkSanitizedField("GetResponse.hash", decodedResponse->hash, resp.hash);
|
|
checkSanitizedField("GetResponse.name", decodedResponse->name, resp.name);
|
|
checkSanitizedField("GetResponse.path", decodedResponse->path, resp.path);
|
|
REQUIRE_FALSE(decodedResponse->hasContent);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("GrepResponse: Serialization edge cases", "[daemon][protocol][grep]") {
|
|
SECTION("Paths-only mode (file only, no line)") {
|
|
GrepResponse in{};
|
|
GrepMatch m{};
|
|
std::string candidate = "/tmp/test/README.md";
|
|
m.file = candidate;
|
|
m.line = "";
|
|
in.matches.push_back(m);
|
|
|
|
Message msg{};
|
|
msg.payload = Response{std::in_place_type<GrepResponse>, in};
|
|
|
|
auto enc = ProtoSerializer::encode_payload(msg);
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
const auto& resp = std::get<Response>(dec.value().payload);
|
|
REQUIRE(std::holds_alternative<GrepResponse>(resp));
|
|
const GrepResponse& gr = std::get<GrepResponse>(resp);
|
|
|
|
REQUIRE(gr.matches.size() == 1);
|
|
REQUIRE(gr.matches[0].file == candidate);
|
|
REQUIRE(gr.matches[0].line.empty());
|
|
}
|
|
|
|
SECTION("Line match preserves content") {
|
|
GrepResponse in{};
|
|
GrepMatch m{};
|
|
m.file = "";
|
|
m.line = "hello world";
|
|
in.matches.push_back(m);
|
|
|
|
Message msg{};
|
|
msg.payload = Response{std::in_place_type<GrepResponse>, in};
|
|
|
|
auto enc = ProtoSerializer::encode_payload(msg);
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
const GrepResponse& gr = std::get<GrepResponse>(std::get<Response>(dec.value().payload));
|
|
REQUIRE(gr.matches.size() == 1);
|
|
REQUIRE(gr.matches[0].line == "hello world");
|
|
REQUIRE(gr.matches[0].file.empty());
|
|
}
|
|
|
|
SECTION("Binary content preservation") {
|
|
GrepResponse in{};
|
|
GrepMatch m{};
|
|
m.file = "binary.bin";
|
|
m.line = std::string("\xFF\x00\x1B", 3);
|
|
m.contextBefore.push_back(std::string("\x01\x02", 2));
|
|
m.contextAfter.push_back(std::string("ok", 2));
|
|
in.matches.push_back(m);
|
|
|
|
Message msg{};
|
|
msg.payload = Response{std::in_place_type<GrepResponse>, in};
|
|
|
|
auto enc = ProtoSerializer::encode_payload(msg);
|
|
REQUIRE(enc);
|
|
|
|
auto dec = ProtoSerializer::decode_payload(enc.value());
|
|
REQUIRE(dec);
|
|
|
|
const GrepResponse& gr = std::get<GrepResponse>(std::get<Response>(dec.value().payload));
|
|
REQUIRE(gr.matches.size() == 1);
|
|
REQUIRE(gr.matches[0].line == std::string("\xFF\x00\x1B", 3));
|
|
REQUIRE(gr.matches[0].contextBefore.size() == 1);
|
|
REQUIRE(gr.matches[0].contextAfter.size() == 1);
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Type System Tests (ResponseOf, MessageType)
|
|
// =============================================================================
|
|
|
|
TEST_CASE("ResponseOf: Compile-time request→response mappings", "[daemon][protocol][types]") {
|
|
// Search operations
|
|
static_assert(std::is_same_v<ResponseOfT<SearchRequest>, SearchResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<AddDocumentRequest>, AddDocumentResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<GetRequest>, GetResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<DeleteRequest>, DeleteResponse>);
|
|
|
|
// System operations
|
|
static_assert(std::is_same_v<ResponseOfT<StatusRequest>, StatusResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<PingRequest>, PongResponse>);
|
|
|
|
// Model operations
|
|
static_assert(std::is_same_v<ResponseOfT<GenerateEmbeddingRequest>, EmbeddingResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<BatchEmbeddingRequest>, BatchEmbeddingResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<LoadModelRequest>, ModelLoadResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<UnloadModelRequest>, SuccessResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<ModelStatusRequest>, ModelStatusResponse>);
|
|
|
|
// Download and other operations
|
|
static_assert(std::is_same_v<ResponseOfT<DownloadRequest>, DownloadResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<GrepRequest>, GrepResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<UpdateDocumentRequest>, UpdateDocumentResponse>);
|
|
static_assert(std::is_same_v<ResponseOfT<GetStatsRequest>, GetStatsResponse>);
|
|
|
|
SUCCEED();
|
|
}
|
|
|
|
TEST_CASE("ResponseOf: Runtime usage with actual instances", "[daemon][protocol][types]") {
|
|
SearchRequest searchReq{"test query", 10, false, false, 0.7, {}, "keyword", false,
|
|
false, false, false, false, 0, 0, 0, ""};
|
|
using SearchResType = ResponseOfT<decltype(searchReq)>;
|
|
static_assert(std::is_same_v<SearchResType, SearchResponse>);
|
|
|
|
SearchResType searchRes;
|
|
searchRes.totalCount = 5;
|
|
REQUIRE(searchRes.totalCount == 5);
|
|
|
|
GenerateEmbeddingRequest embedReq{"sample text", "model"};
|
|
using EmbedResType = ResponseOfT<decltype(embedReq)>;
|
|
static_assert(std::is_same_v<EmbedResType, EmbeddingResponse>);
|
|
|
|
EmbedResType embedRes;
|
|
embedRes.dimensions = 384;
|
|
REQUIRE(embedRes.dimensions == 384);
|
|
}
|
|
|
|
TEST_CASE("MessageType: Request and response enum mappings", "[daemon][protocol][types]") {
|
|
SECTION("Request type mappings") {
|
|
REQUIRE(MessageType::CatRequest == getMessageType(Request{CatRequest{}}));
|
|
REQUIRE(MessageType::ListSessionsRequest == getMessageType(Request{ListSessionsRequest{}}));
|
|
REQUIRE(MessageType::DownloadStatusRequest ==
|
|
getMessageType(Request{DownloadStatusRequest{.jobId = "download-1"}}));
|
|
REQUIRE(MessageType::CancelDownloadJobRequest ==
|
|
getMessageType(Request{CancelDownloadJobRequest{.jobId = "download-2"}}));
|
|
REQUIRE(MessageType::ListDownloadJobsRequest ==
|
|
getMessageType(Request{ListDownloadJobsRequest{.limit = 5}}));
|
|
REQUIRE(MessageType::UseSessionRequest ==
|
|
getMessageType(Request{UseSessionRequest{.session_name = "s"}}));
|
|
REQUIRE(MessageType::AddPathSelectorRequest ==
|
|
getMessageType(Request{AddPathSelectorRequest{.session_name = "s", .path = "/p"}}));
|
|
REQUIRE(
|
|
MessageType::RemovePathSelectorRequest ==
|
|
getMessageType(Request{RemovePathSelectorRequest{.session_name = "s", .path = "/p"}}));
|
|
}
|
|
|
|
SECTION("Response type mappings") {
|
|
REQUIRE(MessageType::CatResponse == getMessageType(Response{CatResponse{}}));
|
|
REQUIRE(MessageType::ListResponse == getMessageType(Response{ListResponse{}}));
|
|
REQUIRE(MessageType::StatusResponse == getMessageType(Response{StatusResponse{}}));
|
|
REQUIRE(MessageType::ListDownloadJobsResponse ==
|
|
getMessageType(Response{ListDownloadJobsResponse{}}));
|
|
}
|
|
|
|
SECTION("Request name extraction") {
|
|
REQUIRE(std::string("Cat") == getRequestName(Request{CatRequest{}}));
|
|
REQUIRE(std::string("ListSessions") == getRequestName(Request{ListSessionsRequest{}}));
|
|
REQUIRE(std::string("DownloadStatus") ==
|
|
getRequestName(Request{DownloadStatusRequest{.jobId = "download-1"}}));
|
|
REQUIRE(std::string("CancelDownloadJob") ==
|
|
getRequestName(Request{CancelDownloadJobRequest{.jobId = "download-2"}}));
|
|
REQUIRE(std::string("ListDownloadJobs") ==
|
|
getRequestName(Request{ListDownloadJobsRequest{.limit = 5}}));
|
|
REQUIRE(std::string("UseSession") ==
|
|
getRequestName(Request{UseSessionRequest{.session_name = "x"}}));
|
|
REQUIRE(std::string("AddPathSelector") ==
|
|
getRequestName(Request{AddPathSelectorRequest{}}));
|
|
REQUIRE(std::string("RemovePathSelector") ==
|
|
getRequestName(Request{RemovePathSelectorRequest{}}));
|
|
}
|
|
}
|