feat: Add LSP plugin unit tests and frontend common initialization library
Some checks failed
CI / Determine matrix (push) Has been cancelled
CI / Sanitizer (ASan+UBSan) / ubuntu-24.04 (push) Has been cancelled
CI / Coverage (gcovr) / ubuntu-24.04 (push) Has been cancelled
CI / ${{ matrix.os }} / ${{ matrix.build_type }} (push) Has been cancelled

- Introduced `dstalk_lsp_plugin_test` for testing LSP plugin functionalities including `lsp_trim`, `lsp_frame_message`, and `lsp_parse_content_length`.
- Created `dstalk_frontend_common` static library to encapsulate shared initialization logic for frontend components (CLI, GUI, Web).
- Implemented configuration file discovery and service querying in `dstalk_frontend_init`.
- Added internal headers for LSP and Anthropic plugins to facilitate unit testing.
- Established a mailroom system for asynchronous message passing between stateless agents, enhancing coordination and context management.
This commit is contained in:
2026-06-01 08:51:40 +08:00
parent 8faa02c3d5
commit c0af9c65c7
17 changed files with 1235 additions and 69 deletions

View File

@@ -35,6 +35,54 @@ namespace asio = boost::asio;
namespace ssl = boost::asio::ssl;
using tcp = asio::ip::tcp;
// ============================================================
// 安全常量和输入验证辅助函数 / Security constants and input-validation helpers
// ============================================================
static constexpr size_t MAX_HEADER_KEY_LENGTH = 256;
static constexpr size_t MAX_HEADER_VALUE_LENGTH = 8192;
/// 如果字符串包含任何控制字符(< 0x20 或 0x7F DEL返回 true / Return true if the string contains any control character (< 0x20 or 0x7F DEL).
static bool contains_control_chars(const char* s) {
if (!s) return false;
for (const char* p = s; *p; ++p) {
unsigned char c = static_cast<unsigned char>(*p);
if (c < 0x20u || c == 0x7Fu) return true;
}
return false;
}
/// 基本端口/服务名验证。拒绝空值,对数字端口进行范围检查,
/// 对符号服务名要求字母数字加连字符RFC 6335/ Basic port / service-name validation.
/// Rejects empty, bounds-checks numeric ports, and requires alphanumeric+hyphen
/// for symbolic service names (RFC 6335).
static bool is_valid_port(const char* port) {
if (!port || !*port) return false;
bool all_digits = true;
for (const char* p = port; *p; ++p) {
if (static_cast<unsigned char>(*p) < '0' ||
static_cast<unsigned char>(*p) > '9') {
all_digits = false;
break;
}
}
if (all_digits) {
if (std::strlen(port) > 5) return false; // > 65535
long p = std::atol(port);
return p > 0 && p <= 65535;
}
// 服务名字母数字加连字符RFC 6335最长15个字符 / Service name: alphanumeric plus hyphen (RFC 6335); max 15 chars
for (const char* p = port; *p; ++p) {
unsigned char c = static_cast<unsigned char>(*p);
if (!((c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-')) {
return false;
}
}
return std::strlen(port) <= 15;
}
// ============================================================
// 全局状态 / Global state
// ============================================================
@@ -42,8 +90,11 @@ static const dstalk_host_api_t* g_host = nullptr;
static dstalk_config_service_t* g_config_svc = nullptr;
// ============================================================
// 极简 JSON 头解析器 / Minimal JSON header parser
// 极简 JSON 头解析器(含安全长度限制)/ Minimal JSON header parser (with security length limits)
// 将 {"key1":"value1","key2":"value2"} 解析到 unordered_map / Parses {"key1":"value1","key2":"value2"} into unordered_map
// 强制 MAX_HEADER_KEY_LENGTH (256) 和 MAX_HEADER_VALUE_LENGTH (8192) 限制,
// 防止恶意输入导致资源耗尽 / Enforces MAX_HEADER_KEY_LENGTH (256) and MAX_HEADER_VALUE_LENGTH
// (8192) to prevent resource exhaustion from malicious input.
// ============================================================
// 将扁平 JSON 对象中的字符串键值对解析到 unordered_map / Parse a flat JSON object of string key-value pairs into an unordered_map.
static std::unordered_map<std::string, std::string> parse_headers_json(const char* json) {
@@ -55,31 +106,53 @@ static std::unordered_map<std::string, std::string> parse_headers_json(const cha
enum { OUTSIDE, IN_KEY, AFTER_KEY, IN_VALUE } state = OUTSIDE;
std::string current_key;
std::string current_value;
bool key_too_long = false;
for (size_t i = 0; i < s.size(); ++i) {
char c = s[i];
switch (state) {
case OUTSIDE:
if (c == '"') { state = IN_KEY; current_key.clear(); }
if (c == '"') { state = IN_KEY; current_key.clear(); key_too_long = false; }
break;
case IN_KEY:
if (c == '"') { state = AFTER_KEY; }
else if (c == '\\' && i + 1 < s.size()) { current_key += s[++i]; }
else { current_key += c; }
else if (c == '\\' && i + 1 < s.size()) {
if (current_key.size() < MAX_HEADER_KEY_LENGTH)
current_key += s[++i];
else { ++i; key_too_long = true; }
}
else {
if (current_key.size() < MAX_HEADER_KEY_LENGTH)
current_key += c;
else
key_too_long = true;
}
break;
case AFTER_KEY:
if (c == ':') { state = IN_VALUE; current_value.clear(); }
// 跳过键和冒号之间的多余字符(例如 ',' 或 '}'/ Skip stray characters between key and colon (e.g. ',' or '}')
else if (c == '"' || c == ',' || c == '}') { /* 保持 AFTER_KEY 状态,忽略 / stay in AFTER_KEY, ignore */ }
break;
case IN_VALUE:
if (c == '"') {
// 读取到闭合引号 / Read until closing quote
++i;
while (i < s.size() && s[i] != '"') {
if (s[i] == '\\' && i + 1 < s.size()) { current_value += s[++i]; }
else { current_value += s[i]; }
if (s[i] == '\\' && i + 1 < s.size()) {
if (current_value.size() < MAX_HEADER_VALUE_LENGTH)
current_value += s[++i];
else
++i; // 跳过转义字符,值已截断 / skip escaped char, value truncated
}
else {
if (current_value.size() < MAX_HEADER_VALUE_LENGTH)
current_value += s[i];
}
++i;
}
headers[current_key] = current_value;
if (!key_too_long) {
headers[current_key] = current_value;
}
state = OUTSIDE;
}
break;
@@ -93,19 +166,76 @@ static std::unordered_map<std::string, std::string> parse_headers_json(const cha
// ============================================================
struct HttpClientCtx {
asio::io_context ioc;
ssl::context ssl_ctx{ssl::context::tlsv12_client};
ssl::context ssl_ctx{ssl::context::tls_client};
int connect_timeout = 30;
int request_timeout = 120;
HttpClientCtx() {
ssl_ctx.set_default_verify_paths();
// 启用对等证书验证 (CVSS 7.4 修复) / Enable peer certificate verification (CVSS 7.4 fix).
// set_default_verify_paths() 加载系统 CA 包;没有 verify_peer
// CA 存储不会被查询——任何证书(自签名/过期)都将被接受 / set_default_verify_paths() loads system CA bundle; without verify_peer
// the CA store is never consulted — any cert (self-signed/expired) is accepted.
// TODO: Windows: set_default_verify_paths() 可能无法定位系统 CA
// 如果验证失败,设置 SSL_CERT_FILE 环境变量或捆绑 cacert.pem / Windows: set_default_verify_paths() may not locate system CAs;
// if verification fails, set SSL_CERT_FILE env or bundle a cacert.pem.
// TLS 1.2+ 协商tls_client 允许 TLS 1.2 和 1.3/ TLS 1.2+ negotiation (tls_client allows TLS 1.2 and 1.3).
// 启用针对系统 CA 存储的对等证书验证。在 Windows 上
// set_default_verify_paths() 可能无法定位系统 CA
// 检测到这种情况时尝试回退源 / Enable peer certificate verification against system CA store.
// On Windows set_default_verify_paths() may not locate system CAs;
// we detect that case and try fallback sources.
boost::system::error_code ec;
ssl_ctx.set_default_verify_paths(ec);
if (ec) {
// 主路径失败——按顺序尝试回退源 / Primary path failed — try fallback sources in order
bool loaded = false;
// 回退 1SSL_CERT_FILE / SSL_CERT_DIROpenSSL 内部已查询,
// 但显式 load_verify_file 提供明确的错误码用于报告)/ Fallback 1: SSL_CERT_FILE / SSL_CERT_DIR (already consulted by
// OpenSSL internally, but an explicit load_verify_file gives us
// a clear error code to report).
const char* cert_file = std::getenv("SSL_CERT_FILE");
if (cert_file && *cert_file) {
ssl_ctx.load_verify_file(cert_file, ec);
if (!ec) loaded = true;
}
if (!loaded) {
const char* cert_dir = std::getenv("SSL_CERT_DIR");
if (cert_dir && *cert_dir) {
ssl_ctx.add_verify_path(cert_dir, ec);
if (!ec) loaded = true;
}
}
// 回退 2http.ca_cert_file 配置项 / Fallback 2: http.ca_cert_file config key
if (!loaded && g_config_svc) {
const char* cfg_cert = g_config_svc->get("http.ca_cert_file");
if (cfg_cert && *cfg_cert) {
ssl_ctx.load_verify_file(cfg_cert, ec);
if (!ec) loaded = true;
}
}
// 回退 3捆绑的 cacert.pem相对于常见安装路径 / Fallback 3: bundled cacert.pem relative to common install paths
if (!loaded) {
static const char* kBundlePaths[] = {
"cacert.pem",
"share/cacert.pem",
"../share/cacert.pem",
"certs/cacert.pem",
nullptr
};
for (int pi = 0; kBundlePaths[pi]; ++pi) {
ssl_ctx.load_verify_file(kBundlePaths[pi], ec);
if (!ec) { loaded = true; break; }
}
}
if (!loaded) {
if (g_host) g_host->log(DSTALK_LOG_WARN,
"TLS CA certificates not found. "
"set_default_verify_paths() failed: %s. "
"Set SSL_CERT_FILE=/path/to/cacert.pem or "
"http.ca_cert_file in config. "
"TLS verification will proceed but may fail at handshake.",
ec.message().c_str());
}
}
ssl_ctx.set_verify_mode(ssl::verify_peer);
}
};
@@ -132,6 +262,33 @@ static int do_post_stream(
return -1;
}
// ---- 输入验证(安全加固)/ Input validation (security hardening) ----
// 拒绝 host 和 target 中的控制字符CRLF 注入防护)/ Reject control characters in host and target (CRLF injection prevention)
if (contains_control_chars(host)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: host contains control characters");
*response_body = nullptr;
*status_code = -1;
return -1;
}
if (contains_control_chars(target)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: target contains control characters");
*response_body = nullptr;
*status_code = -1;
return -1;
}
// 验证端口:必须是数字 1-65535 或有效的服务名 / Validate port: must be numeric 1-65535 or a valid service name
if (!is_valid_port(port)) {
if (g_host) g_host->log(DSTALK_LOG_ERROR,
"do_post_stream: invalid port '%s'", port);
*response_body = nullptr;
*status_code = -1;
return -1;
}
// 初始化输出 / Initialize output
*response_body = nullptr;
*status_code = -1;