From 127191c889a027ed60526bb352d4eb96ff285a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Thu, 10 Sep 2026 11:01:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20=E7=81=B5=E5=8F=B0=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E8=84=BE=E7=A5=9E=EF=BC=88hj=5Ftransform=EF=BC=89?= =?UTF-8?q?=E7=A1=AE=E5=AE=9A=E6=80=A7=20Markdown=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=99=A8=EF=BC=88WP-15=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C ABI 新增 hj_transform_version / hj_transform_markdown(契约登记同步);HjBuffer 真实实现(头部+紧随数据) - v1 解析(hunjing-markdown-v1):fenced code/mermaid 提取、独占行 $$ 块级数学、未闭合结构保持源码态、其余归并 markdown block;canonical JSON 键序确定、UTF-8 校验、1MiB/256 block/64KiB 行硬限额 - 引擎内部设施抽取(engine_internal.h:last_error/句柄表/buffer 共享) - doctest 12 个用例(含 1 万次随机鲁棒性冒烟,固定种子可复现);libFuzzer 目标(-DHJ_BUILD_FUZZER,Apple clang 无 fuzzer 运行时,正式跑在 Linux CI) - nm 验证 9 个导出符号全部 hj_* --- .gitignore | 2 +- engine/CMakeLists.txt | 21 ++- engine/fuzz/fuzz_transform.cpp | 28 +++ engine/include/hunjing/lingtai.h | 11 ++ engine/src/engine_internal.h | 26 +++ engine/src/lingtai.cpp | 55 +++--- engine/src/transform.cpp | 305 +++++++++++++++++++++++++++++++ engine/tests/test_core.cpp | 1 - engine/tests/test_main.cpp | 3 + engine/tests/test_transform.cpp | 139 ++++++++++++++ 10 files changed, 565 insertions(+), 26 deletions(-) create mode 100644 engine/fuzz/fuzz_transform.cpp create mode 100644 engine/src/engine_internal.h create mode 100644 engine/src/transform.cpp create mode 100644 engine/tests/test_main.cpp create mode 100644 engine/tests/test_transform.cpp diff --git a/.gitignore b/.gitignore index 36fdb8b..ac7c53e 100644 --- a/.gitignore +++ b/.gitignore @@ -111,7 +111,7 @@ docker-compose.override.yml target/ # C++ 引擎与契约生成产物(源码构建可再生) -engine/build/ +engine/build*/ openapi/bundled.json **/*.rs.bk sandbox-work/ diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 2aa31ed..1275eb2 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -17,14 +17,29 @@ endif() set(CMAKE_CXX_VISIBILITY_PRESET hidden) set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) -add_library(hunjing SHARED src/lingtai.cpp) -target_include_directories(hunjing PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +add_library(hunjing SHARED src/lingtai.cpp src/transform.cpp) +target_include_directories(hunjing + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) # 单元测试(doctest,ADR-025);单头直接引入,不用 FetchContent 拉网络 -add_executable(lingtai_tests tests/test_core.cpp src/lingtai.cpp) +add_executable(lingtai_tests tests/test_main.cpp tests/test_core.cpp tests/test_transform.cpp + src/lingtai.cpp src/transform.cpp) target_include_directories(lingtai_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src ${CMAKE_CURRENT_SOURCE_DIR}/third_party) enable_testing() add_test(NAME lingtai_tests COMMAND lingtai_tests) + +# fuzz 目标(ADR-025:hj_transform 为必测目标;需要 clang libFuzzer) +option(HJ_BUILD_FUZZER "Build libFuzzer targets" OFF) +if(HJ_BUILD_FUZZER) + add_executable(fuzz_transform fuzz/fuzz_transform.cpp src/lingtai.cpp src/transform.cpp) + target_include_directories(fuzz_transform PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/include + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_compile_options(fuzz_transform PRIVATE -fsanitize=fuzzer,address -g) + target_link_options(fuzz_transform PRIVATE -fsanitize=fuzzer,address) +endif() diff --git a/engine/fuzz/fuzz_transform.cpp b/engine/fuzz/fuzz_transform.cpp new file mode 100644 index 0000000..d90ce4a --- /dev/null +++ b/engine/fuzz/fuzz_transform.cpp @@ -0,0 +1,28 @@ +// hj_transform fuzz 目标(ADR-025:libFuzzer 必测)。 +// 构建:cmake -DHJ_BUILD_FUZZER=ON(需要 clang 的 -fsanitize=fuzzer)。 +#include +#include + +#include "hunjing/lingtai.h" + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + HjEngine *engine = nullptr; + if (hj_engine_new(HJ_ABI_VERSION, &engine) != HJ_OK) { + return 0; + } + HjBuffer *buf = nullptr; + const HjStatus st = hj_transform_markdown(engine, reinterpret_cast(data), size, &buf); + if (st == HJ_OK) { + // 输出必须可读且与声称长度一致 + const void *p = hj_buffer_data(buf); + const size_t len = hj_buffer_len(buf); + if (p == nullptr || len == 0) { + hj_buffer_free(buf); + hj_engine_free(engine); + __builtin_trap(); + } + hj_buffer_free(buf); + } + hj_engine_free(engine); + return 0; +} diff --git a/engine/include/hunjing/lingtai.h b/engine/include/hunjing/lingtai.h index 140af95..4de9fb3 100644 --- a/engine/include/hunjing/lingtai.h +++ b/engine/include/hunjing/lingtai.h @@ -65,6 +65,17 @@ HJ_API size_t hj_buffer_len(const HjBuffer *buffer); // 线程局部的最近一次错误消息(UTF-8、脱敏、不含正文与密钥);下一次调用覆盖。 HJ_API const char *hj_last_error(void); +// ===== 脾神(hj_transform):Markdown → canonical ContentBlock 确定性解析 ===== + +// 返回当前 parser version(稳定标识,随实现演进递增)。 +HJ_API const char *hj_transform_version(void); + +// 将 Markdown 规范化为 canonical ContentBlock JSON(键序确定、UTF-8)。 +// 输出经 HjBuffer 返回,由 hj_buffer_free 释放;blocks 不含 id(由宿主生成)。 +// 限额与非法 UTF-8 分别返回 HJ_ERR_LIMIT_EXCEEDED / HJ_ERR_INVALID_ARGUMENT。 +HJ_API HjStatus hj_transform_markdown( + HjEngine *engine, const char *input, size_t input_len, HjBuffer **out); + #ifdef __cplusplus } // extern "C" #endif diff --git a/engine/src/engine_internal.h b/engine/src/engine_internal.h new file mode 100644 index 0000000..f586dfb --- /dev/null +++ b/engine/src/engine_internal.h @@ -0,0 +1,26 @@ +// 灵台引擎内部共享设施(非公共契约,仅引擎源文件使用)。 +#ifndef HUNJING_LINGTAI_INTERNAL_H +#define HUNJING_LINGTAI_INTERNAL_H + +#include +#include +#include +#include + +#include "hunjing/lingtai.h" + +// HjBuffer 布局:头部 + 紧随的字节数据(data = buffer + 1)。 +struct HjBuffer { + size_t len; +}; + +// 线程局部错误消息。 +void hj_internal_set_error(const char *message); + +// 句柄表:活跃引擎登记与校验(线程安全)。 +bool hj_internal_engine_valid(HjEngine *engine); + +// 分配输出缓冲;失败返回 nullptr(由调用方映射为 HJ_ERR_OUT_OF_MEMORY)。 +HjBuffer *hj_internal_buffer_new(std::string_view data); + +#endif // HUNJING_LINGTAI_INTERNAL_H diff --git a/engine/src/lingtai.cpp b/engine/src/lingtai.cpp index 89b7e87..4419040 100644 --- a/engine/src/lingtai.cpp +++ b/engine/src/lingtai.cpp @@ -1,55 +1,68 @@ -// 灵台引擎核心(太一):版本协商、句柄表与错误上下文的最小实现。 +// 灵台引擎核心(太一):版本协商、句柄表、缓冲与错误上下文的实现。 // 全部入口按不可信输入处理;异常不得跨越 ABI 边界。 #include "hunjing/lingtai.h" #include #include +#include #include #include #include #include -// 不透明句柄的实际布局(不进入契约;仅本翻译单元可见字段)。 +#include "engine_internal.h" + +// 不透明句柄的实际布局(仅本翻译单元可见字段)。 struct HjEngine { uint32_t abi_major; }; -struct HjBuffer { - size_t len; -}; - namespace { -// 线程局部错误消息(脱敏、UTF-8);下一次引擎调用覆盖。 thread_local std::string g_last_error; -void set_last_error(const char *message) { g_last_error = message; } - -// 句柄表:登记活跃引擎句柄,支撑二次释放与非法句柄检测(不崩溃)。 std::mutex g_handle_table_mutex; std::unordered_set g_handle_table; } // namespace +void hj_internal_set_error(const char *message) { g_last_error = message; } + +bool hj_internal_engine_valid(HjEngine *engine) { + const std::lock_guard lock(g_handle_table_mutex); + return g_handle_table.find(engine) != g_handle_table.end(); +} + +HjBuffer *hj_internal_buffer_new(std::string_view data) { + void *mem = std::malloc(sizeof(HjBuffer) + data.size()); + if (mem == nullptr) return nullptr; + auto *buf = static_cast(mem); + buf->len = data.size(); + if (!data.empty()) { + std::memcpy(buf + 1, data.data(), data.size()); + } + return buf; +} + extern "C" { uint32_t hj_abi_version(void) { return HJ_ABI_VERSION; } HjStatus hj_engine_new(uint32_t required_abi_major, HjEngine **out_engine) { try { - set_last_error(""); + g_last_error.clear(); if (out_engine == nullptr) { - set_last_error("out_engine 不能为空"); + g_last_error = "out_engine 不能为空"; return HJ_ERR_INVALID_ARGUMENT; } *out_engine = nullptr; if (required_abi_major != HJ_ABI_VERSION) { - set_last_error("ABI major 不匹配:拒绝初始化,不静默降级"); + g_last_error = "ABI major 不匹配:拒绝初始化,不静默降级"; return HJ_ERR_UNSUPPORTED_ABI; } auto *engine = new (std::nothrow) HjEngine{required_abi_major}; if (engine == nullptr) { - set_last_error("引擎内存分配失败"); + g_last_error = "引擎内存分配失败"; return HJ_ERR_OUT_OF_MEMORY; } { @@ -60,40 +73,40 @@ HjStatus hj_engine_new(uint32_t required_abi_major, HjEngine **out_engine) { return HJ_OK; } catch (...) { // 入口捕获一切异常并转换为 HjStatus(契约禁令)。 - set_last_error("引擎内部错误"); + g_last_error = "引擎内部错误"; return HJ_ERR_INTERNAL; } } HjStatus hj_engine_free(HjEngine *engine) { try { - set_last_error(""); + g_last_error.clear(); { const std::lock_guard lock(g_handle_table_mutex); if (g_handle_table.erase(engine) == 0) { - set_last_error("句柄不在登记表:已释放或非法"); + g_last_error = "句柄不在登记表:已释放或非法"; return HJ_ERR_HANDLE_INVALID; } } delete engine; return HJ_OK; } catch (...) { - set_last_error("引擎内部错误"); + g_last_error = "引擎内部错误"; return HJ_ERR_INTERNAL; } } HjStatus hj_buffer_free(HjBuffer *buffer) { try { - set_last_error(""); + g_last_error.clear(); if (buffer == nullptr) { - set_last_error("buffer 句柄为空"); + g_last_error = "buffer 句柄为空"; return HJ_ERR_INVALID_ARGUMENT; } free(buffer); return HJ_OK; } catch (...) { - set_last_error("引擎内部错误"); + g_last_error = "引擎内部错误"; return HJ_ERR_INTERNAL; } } diff --git a/engine/src/transform.cpp b/engine/src/transform.cpp new file mode 100644 index 0000000..b91b73f --- /dev/null +++ b/engine/src/transform.cpp @@ -0,0 +1,305 @@ +// 脾神(hj_transform):Markdown → canonical ContentBlock 确定性解析器。 +// +// v1 支持范围(parser_version = hunjing-markdown-v1): +// - fenced 代码块(```lang)→ code block;```mermaid → mermaid block; +// - 独占行的 $$ … $$(闭合)→ math block(display=block); +// - 未闭合的 fenced 或数学定界保持源码态,归入 markdown block; +// - 其余内容合并为 markdown block(前后空白裁剪)。 +// 行内 $...$ 数学与完整方言解析随 WP-03 迭代(版本号同步递增)。 +// +// 全部输入按不可信处理:长度与结构硬限额、UTF-8 校验;输出 JSON 键序确定。 +#include "hunjing/lingtai.h" + +#include +#include +#include +#include +#include +#include + +#include "engine_internal.h" + +namespace { + +constexpr size_t kMaxInputBytes = 1 << 20; // 单次输入上限 1 MiB +constexpr size_t kMaxBlocks = 256; // 单次产出的 block 数上限 +constexpr size_t kMaxLineBytes = 1 << 16; // 单行上限 64 KiB + +// —— UTF-8 校验(拒绝截断与非法序列)—— +bool valid_utf8(std::string_view s) { + size_t i = 0; + const size_t n = s.size(); + while (i < n) { + const unsigned char c = static_cast(s[i]); + if (c < 0x80) { + ++i; + continue; + } + size_t len = 0; + unsigned int min = 0; + if ((c & 0xE0) == 0xC0) { len = 2; min = 0x80; } + else if ((c & 0xF0) == 0xE0) { len = 3; min = 0x800; } + else if ((c & 0xF8) == 0xF0) { len = 4; min = 0x10000; } + else { return false; } + if (i + len > n) { return false; } + unsigned int cp = c & (0x7F >> len); + for (size_t k = 1; k < len; ++k) { + const unsigned char cc = static_cast(s[i + k]); + if ((cc & 0xC0) != 0x80) { return false; } + cp = (cp << 6) | (cc & 0x3F); + } + if (cp < min || cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) { return false; } + i += len; + } + return true; +} + +// —— 确定性 JSON 输出 —— +void json_escape_append(std::string &out, std::string_view value) { + out.push_back('"'); + for (char ch : value) { + const unsigned char c = static_cast(ch); + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (c < 0x20) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", c); + out += buf; + } else { + out.push_back(ch); + } + } + } + out.push_back('"'); +} + +std::string trim(std::string_view s) { + size_t b = 0, e = s.size(); + while (b < e && (s[b] == ' ' || s[b] == '\t' || s[b] == '\r' || s[b] == '\n')) ++b; + while (e > b && (s[e - 1] == ' ' || s[e - 1] == '\t' || s[e - 1] == '\r' || s[e - 1] == '\n')) --e; + return std::string(s.substr(b, e - b)); +} + +bool starts_with_fence(std::string_view line, std::string *lang) { + size_t i = 0; + while (i < line.size() && line[i] == '`') ++i; + if (i < 3) return false; + *lang = trim(line.substr(i)); + return true; +} + +bool is_fence_end(std::string_view line) { + size_t i = 0; + while (i < line.size() && line[i] == '`') ++i; + return i >= 3 && trim(std::string_view(line).substr(i)).empty(); +} + +bool is_block_math_delim(std::string_view line) { return trim(line) == "$$"; } + +struct Block { + std::string type; // markdown / code / mermaid / math + std::string text; // markdown 文本 + std::string source; // code/mermaid/math 源码 + std::string language; // code 语言(可空) + bool display_block = true; // math:block/inline(v1 仅 block) +}; + +void append_block_json(std::string &out, const Block &b, bool *first) { + if (!*first) out.push_back(','); + *first = false; + out += "{\"type\":"; + json_escape_append(out, b.type); + if (b.type == "markdown") { + out += ",\"text\":"; + json_escape_append(out, b.text); + out += ",\"flavor\":\"hunjing-gfm-v1\""; + } else if (b.type == "code") { + out += ",\"source\":"; + json_escape_append(out, b.source); + if (!b.language.empty()) { + out += ",\"language\":"; + json_escape_append(out, b.language); + } + } else if (b.type == "mermaid") { + out += ",\"source\":"; + json_escape_append(out, b.source); + } else { // math + out += ",\"notation\":\"latex\",\"display\":\"block\",\"source\":"; + json_escape_append(out, b.source); + } + out += ",\"status\":\"completed\"}"; +} + +// 主解析:逐行扫描。未闭合结构整体保持源码态(契约第 4 节)。 +bool parse_markdown(std::string_view input, std::string *error, std::string *out_json) { + std::vector lines; + { + size_t start = 0; + while (start <= input.size()) { + size_t nl = input.find('\n', start); + if (nl == std::string_view::npos) { + if (start < input.size()) lines.emplace_back(input.substr(start)); + break; + } + lines.emplace_back(input.substr(start, nl - start)); + start = nl + 1; + } + } + for (const auto &line : lines) { + if (line.size() > kMaxLineBytes) { + *error = "单行超过长度上限"; + return false; + } + } + + std::vector blocks; + std::string text_acc; + + auto flush_text = [&]() { + const std::string t = trim(text_acc); + if (!t.empty()) { + blocks.push_back(Block{.type = "markdown", .text = t}); + } + text_acc.clear(); + }; + + size_t i = 0; + const size_t n = lines.size(); + while (i < n) { + std::string lang; + if (starts_with_fence(lines[i], &lang)) { + std::string source; + size_t j = i + 1; + bool closed = false; + while (j < n) { + if (is_fence_end(lines[j])) { closed = true; break; } + if (!source.empty()) source.push_back('\n'); + source += lines[j]; + ++j; + } + if (!closed) { + // 未闭合:整段保持源码态并入 markdown 文本 + if (!text_acc.empty()) text_acc.push_back('\n'); + text_acc += lines[i]; + for (size_t k = i + 1; k < n; ++k) { + text_acc.push_back('\n'); + text_acc += lines[k]; + } + i = n; + break; + } + flush_text(); + if (lang == "mermaid") { + blocks.push_back(Block{.type = "mermaid", .source = source}); + } else { + Block b{.type = "code", .source = source, .language = lang}; + blocks.push_back(b); + } + i = j + 1; + continue; + } + if (is_block_math_delim(lines[i])) { + std::string source; + size_t j = i + 1; + bool closed = false; + while (j < n) { + if (is_block_math_delim(lines[j])) { closed = true; break; } + if (!source.empty()) source.push_back('\n'); + source += lines[j]; + ++j; + } + if (!closed) { + if (!text_acc.empty()) text_acc.push_back('\n'); + text_acc += lines[i]; + for (size_t k = i + 1; k < n; ++k) { + text_acc.push_back('\n'); + text_acc += lines[k]; + } + i = n; + break; + } + flush_text(); + blocks.push_back(Block{.type = "math", .source = trim(source)}); + i = j + 1; + continue; + } + if (!text_acc.empty()) text_acc.push_back('\n'); + text_acc += lines[i]; + ++i; + } + flush_text(); + + if (blocks.size() > kMaxBlocks) { + *error = "block 数超过上限"; + return false; + } + + out_json->clear(); + out_json->append("{\"parser_version\":\""); + out_json->append(hj_transform_version()); + out_json->append("\",\"blocks\":["); + bool first = true; + for (const auto &b : blocks) { + append_block_json(*out_json, b, &first); + } + out_json->append("]}"); + return true; +} + +} // namespace + +extern "C" { + +const char *hj_transform_version(void) { return "hunjing-markdown-v1"; } + +HjStatus hj_transform_markdown(HjEngine *engine, const char *input, size_t input_len, + HjBuffer **out) { + try { + hj_internal_set_error(""); + if (out == nullptr) { + return HJ_ERR_INVALID_ARGUMENT; + } + *out = nullptr; + if (!hj_internal_engine_valid(engine)) { + hj_internal_set_error("engine 句柄不在登记表"); + return HJ_ERR_HANDLE_INVALID; + } + if (input == nullptr && input_len > 0) { + hj_internal_set_error("input 为空但长度非零"); + return HJ_ERR_INVALID_ARGUMENT; + } + if (input_len > kMaxInputBytes) { + hj_internal_set_error("输入超过长度上限"); + return HJ_ERR_LIMIT_EXCEEDED; + } + const std::string_view view(input == nullptr ? "" : input, input_len); + if (!valid_utf8(view)) { + hj_internal_set_error("输入不是合法 UTF-8"); + return HJ_ERR_INVALID_ARGUMENT; + } + std::string error; + std::string json; + if (!parse_markdown(view, &error, &json)) { + hj_internal_set_error(error.c_str()); + return error.find("上限") != std::string::npos ? HJ_ERR_LIMIT_EXCEEDED + : HJ_ERR_PARSE_FAILED; + } + HjBuffer *buf = hj_internal_buffer_new(json); + if (buf == nullptr) { + hj_internal_set_error("输出缓冲分配失败"); + return HJ_ERR_OUT_OF_MEMORY; + } + *out = buf; + return HJ_OK; + } catch (...) { + hj_internal_set_error("引擎内部错误"); + return HJ_ERR_INTERNAL; + } +} + +} // extern "C" diff --git a/engine/tests/test_core.cpp b/engine/tests/test_core.cpp index d05f375..e3793cb 100644 --- a/engine/tests/test_core.cpp +++ b/engine/tests/test_core.cpp @@ -1,5 +1,4 @@ // 灵台引擎核心测试(doctest,ADR-025)。 -#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include #include "hunjing/lingtai.h" diff --git a/engine/tests/test_main.cpp b/engine/tests/test_main.cpp new file mode 100644 index 0000000..dcb2a46 --- /dev/null +++ b/engine/tests/test_main.cpp @@ -0,0 +1,3 @@ +// doctest 入口:整个测试可执行文件只保留一份实现。 +#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN +#include diff --git a/engine/tests/test_transform.cpp b/engine/tests/test_transform.cpp new file mode 100644 index 0000000..9d89872 --- /dev/null +++ b/engine/tests/test_transform.cpp @@ -0,0 +1,139 @@ +// 脾神(hj_transform)测试:确定性解析、提取、未闭合源码态、限额与校验。 +#include +#include + +#include + +#include "hunjing/lingtai.h" + +namespace { + +struct Engine { + HjEngine *ptr = nullptr; + Engine() { REQUIRE(hj_engine_new(HJ_ABI_VERSION, &ptr) == HJ_OK); } + ~Engine() { hj_engine_free(ptr); } +}; + +std::string transform(const Engine &e, std::string_view input, HjStatus *status = nullptr) { + HjBuffer *buf = nullptr; + HjStatus st = hj_transform_markdown(e.ptr, input.data(), input.size(), &buf); + if (status != nullptr) *status = st; + if (st != HJ_OK) return {}; + std::string out(static_cast(hj_buffer_data(buf)), hj_buffer_len(buf)); + hj_buffer_free(buf); + return out; +} + +} // namespace + +TEST_CASE("版本与空输入") { + Engine e; + CHECK(std::string(hj_transform_version()) == "hunjing-markdown-v1"); + const std::string out = transform(e, ""); + CHECK(out == R"jx({"parser_version":"hunjing-markdown-v1","blocks":[]})jx"); +} + +TEST_CASE("纯文本归并为 markdown block 并裁剪空白") { + Engine e; + const std::string out = transform(e, "\n你好,魂镜\n\n第二段\n\n"); + CHECK(out.find(R"jx("type":"markdown")jx") != std::string::npos); + CHECK(out.find("你好,魂镜\\n\\n第二段") != std::string::npos); + CHECK(out.find(R"jx("flavor":"hunjing-gfm-v1")jx") != std::string::npos); +} + +TEST_CASE("fenced code 与 mermaid 提取") { + Engine e; + const std::string input = "前置说明\n```python\nprint(1)\n```\n中间\n```mermaid\nflowchart LR\nA-->B\n```\n结尾"; + const std::string out = transform(e, input); + CHECK(out.find(R"jx("type":"code","source":"print(1)","language":"python")jx") != std::string::npos); + CHECK(out.find(R"jx("type":"mermaid","source":"flowchart LR\nA-->B")jx") != std::string::npos); + CHECK(out.find("前置说明") != std::string::npos); + CHECK(out.find("结尾") != std::string::npos); +} + +TEST_CASE("块级数学定界提取") { + Engine e; + const std::string input = "前文\n$$\nE=mc^2\n$$\n后文"; + const std::string out = transform(e, input); + CHECK(out.find(R"jx("type":"math","notation":"latex","display":"block","source":"E=mc^2")jx") != std::string::npos); +} + +TEST_CASE("未闭合 fenced 保持源码态") { + Engine e; + const std::string input = "开头\n```js\nunterminated("; + const std::string out = transform(e, input); + CHECK(out.find(R"jx("type":"markdown")jx") != std::string::npos); + CHECK(out.find("```js") != std::string::npos); + CHECK(out.find("unterminated(") != std::string::npos); + CHECK(out.find(R"jx("type":"code")jx") == std::string::npos); +} + +TEST_CASE("未闭合数学定界保持源码态") { + Engine e; + const std::string out = transform(e, "a\n$$\nunclosed math"); + CHECK(out.find(R"jx("type":"math")jx") == std::string::npos); + CHECK(out.find("$$") != std::string::npos); +} + +TEST_CASE("确定性:同输入两次输出字节一致") { + Engine e; + const std::string input = "x\n```mermaid\nA-->B\n```\n$$\nz\n$$\n"; + const std::string a = transform(e, input); + const std::string b = transform(e, input); + CHECK(a == b); +} + +TEST_CASE("非法 UTF-8 拒绝") { + Engine e; + HjStatus st = HJ_OK; + transform(e, std::string_view("ab\xff\xfe", 5), &st); + CHECK(st == HJ_ERR_INVALID_ARGUMENT); +} + +TEST_CASE("超长输入拒绝") { + Engine e; + const std::string big((1 << 20) + 1, 'a'); + HjStatus st = HJ_OK; + transform(e, big, &st); + CHECK(st == HJ_ERR_LIMIT_EXCEEDED); +} + +TEST_CASE("参数与句柄校验") { + Engine e; + HjBuffer *buf = nullptr; + CHECK(hj_transform_markdown(e.ptr, "x", 1, nullptr) == HJ_ERR_INVALID_ARGUMENT); + CHECK(hj_transform_markdown(nullptr, "x", 1, &buf) == HJ_ERR_HANDLE_INVALID); + CHECK(hj_transform_markdown(e.ptr, nullptr, 5, &buf) == HJ_ERR_INVALID_ARGUMENT); +} + +TEST_CASE("随机鲁棒性:任意字节输入不崩溃且返回已知状态码") { + // 本地等价冒烟:正式 libFuzzer 目标(fuzz/fuzz_transform.cpp)在 Linux CI 运行。 + Engine e; + uint64_t rng = 0x9E3779B97F4A7C15ULL; // 固定种子,可复现 + auto next = [&rng](uint64_t bound) { + rng ^= rng << 13; rng ^= rng >> 7; rng ^= rng << 17; + return static_cast(rng % bound); + }; + static const char *kPieces[] = { + "text ", "\n", "```", "mermaid\n", "python\n", "$$\n", "`", "$", + "hello", "\xE4\xB8\xAD", "\"", "\\", "flowchart LR\nA-->B\n", "\x7f", + }; + for (int iter = 0; iter < 10000; ++iter) { + std::string input; + if (iter % 2 == 0) { + const size_t len = next(512); + input.reserve(len); + for (size_t k = 0; k < len; ++k) input.push_back(static_cast(next(256))); + } else { + for (size_t k = 0, m = next(24); k < m; ++k) input += kPieces[next(14)]; + } + HjBuffer *buf = nullptr; + const HjStatus st = hj_transform_markdown(e.ptr, input.data(), input.size(), &buf); + CHECK((st == HJ_OK || st == HJ_ERR_INVALID_ARGUMENT || st == HJ_ERR_LIMIT_EXCEEDED || + st == HJ_ERR_PARSE_FAILED)); + if (buf != nullptr) { + CHECK(hj_buffer_data(buf) != nullptr); + CHECK(hj_buffer_free(buf) == HJ_OK); + } + } +}