From 4d3962a845c174f121ef41ff413ed224c1590d87 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 10:07:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(csrc):=20=E7=AC=AC=E4=BA=8C=E5=88=80=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E9=9B=B6=E5=88=86=E9=85=8D=20JSON=20?= =?UTF-8?q?=E6=89=AB=E6=8F=8F/=E5=8F=96=E5=80=BC=E5=B1=82=20ha=5Fjson=5Fsc?= =?UTF-8?q?an=EF=BC=88=E5=90=AB=E9=BB=84=E9=87=91=E5=AF=B9=E7=85=A7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C 化第二刀:为协议编解码层铺 JSON 底座。**本刀只交付库 + 验收, 未改 Go 生产路径**(接线是独立一步,库先验完再换产线)。 为什么是它:SSE 单块解析(parseOpenAICompatibleStreamChunkFull)是每个流式 chunk 都要跑的最热路径,实测 1937ns/13allocs(content 块)、3122ns/21allocs (toolcall 块),而纯字节扫描理论下限 133ns/1alloc —— 差距 15~23×。 一次 1 万块的会话 = 1~2 万次堆分配,正是 GC 抖动的来源。 为什么不复用 SDK 的 remotedevice/ha_json.c(实测三缺陷,不可直接复用): ① 无 \u 解码:\u4f60\u597d → ?0?d?d?0(非 ASCII 全靠转义时内容直接损坏) ② 只有 _get_int 无浮点:temperature:0.7 静默变 0 ③ null 与「键缺失」不可区分 外加它是 DOM + malloc,与本层「不 malloc / 零拷贝 / 纯函数」正交。 设计:scan(结构,零分配零解码)+ extract(取值,按需解码)两段分离。 content 可能是很大的多模态数组,而 stringifyContent 只需要 text 字段拼起来; 若 scan 就解码并分配缓冲,等于把成本付给不需要它的调用方。 ★ 被测试抓出 7 个真实缺陷(写 C 时同一逻辑我读三遍都认为正确): 1 代理对合成成功后未跳过 unconditionally 的 U+FFFD 发射(😀 → 两个 FFFD) 2 过长编码检查用了只含首字节位的 cp(「你」→ 6 个 FFFD) 3 members_next 只报值起点不消费值 → 游标停在值前(模糊测试第一轮抓到) 4 扫描阶段不校验转义字符合法性({"a":"\q"} C 判合法、json.Valid=false) 5 扫描阶段不校验 \u 后四位十六进制(同上) 6 get_int 接受前导零(007 / 00) 7 cgo 桥接把 C 结构体声明为 Go 局部变量 → 运行时 panic (cgo argument has Go pointer to unpinned Go pointer) 其中 4 个是「静默分叉」——不崩、不报错,生产里表现为「内容少一个字符」 或「某些块被静默丢弃」,极难归因。这正是黄金对照不可省的理由。 ★ 另纠正我自己两次错误的「真值」(比代码 bug 更危险,会变成错误规格): 第一版真值表里 content:{} 的花括号少了一层,把「我写错 JSON」误读成 「Go 对 content 严格」。修正后实测发现一对方向相反的语义: content 走 interface{} 宽松({}→"{}"、true→"true"), reasoning_content/usage/finish_reason 强类型严格(123 ⇒ 整块作废)。 照错误表写 C 会产出「比 Go 更严格」的实现,静默丢弃本该生效的块。 两个由缺陷倒逼的设计决定: - members_next 返回**完整值 span** 并内部跳过 ⇒ 「返回 1」蕴含「成员良构」。 要求调用方自己推进游标的 API 是错的:忘一次就解析到上一个值且不报错。 - members_complete() 区分「正常扫到 }」与「输入畸形」,否则无法复刻 Go 严格性。 同时修两个基础设施目标对「多源文件/多测试」的适配: - csrc-sanitize:每个契约测试各自链接(多个 main 合链会 multiple definition, 而报错被吞后会被误报成「本机无 sanitizer」——一个假的 SKIP) - csrc-cross:多源文件改用 -fsyntax-only 逐文件(gcc 不支持多源单 -o) 实测(全部当场可复现): - C 契约测试 119 项断言全过;黄金对照 5 组全过(语法/成员/解码/整数/随机字节) - libFuzzer 4948 万次运行零崩溃(121s) - ASan+UBSan PASS(两个契约测试各跑);gcc+clang 零告警;arm64 交叉编译 0 告警 - 全量 go test -count=1 ./... 0 FAIL;make build-linux-arm64 → ELF aarch64 - 纪律检查 SDK 公开接口 diff = 0 行(未触碰 SDK) 决策关闭(jianf 本轮裁决):C 实现留主仓 csrc/(它本就是替换内核 Go 实现, SDK 从未被触碰,跨端复用才需进 SDK 而它们不调用本层);ha_json.c 不复用; 下一刀即协议编解码层。 --- Makefile | 49 +- csrc/CMakeLists.txt | 6 +- csrc/include/ha_json_scan.h | 232 ++++++ csrc/src/ha_json_scan.c | 825 ++++++++++++++++++++ csrc/test/test_fuzz_ha_json_scan.c | 215 +++++ csrc/test/test_ha_json_scan.c | 414 ++++++++++ docs/zh/c-core/sse-codec-c.md | 217 +++++ internal/agent/api/codec_cgo.go | 21 + internal/agent/api/codec_jsongolden_test.go | 269 +++++++ internal/agent/api/codec_jsonscan_cgo.go | 210 +++++ internal/agent/api/ha_json_scan.c | 1 + internal/agent/api/ha_json_scan.h | 1 + plan.md | 12 + 13 files changed, 2456 insertions(+), 16 deletions(-) create mode 100644 csrc/include/ha_json_scan.h create mode 100644 csrc/src/ha_json_scan.c create mode 100644 csrc/test/test_fuzz_ha_json_scan.c create mode 100644 csrc/test/test_ha_json_scan.c create mode 100644 docs/zh/c-core/sse-codec-c.md create mode 100644 internal/agent/api/codec_jsongolden_test.go create mode 100644 internal/agent/api/codec_jsonscan_cgo.go create mode 120000 internal/agent/api/ha_json_scan.c create mode 120000 internal/agent/api/ha_json_scan.h diff --git a/Makefile b/Makefile index e135a6f..48960f8 100644 --- a/Makefile +++ b/Makefile @@ -126,19 +126,34 @@ csrc-abi: # C 侧没有 Go 的 -race 等价物,sanitizer 就是这里的关等物。 # 若本机无 libasan/libubsan(交叉工具链常见),明确 SKIP 而非静默跳过。 .PHONY: csrc-sanitize +# ★ 每个测试文件**各自**链接成独立二进制:契约测试每个都带 main, +# 合在一起会「multiple definition of main」——而报错被 2>/dev/null +# 吞掉后会被误报成「本机无 sanitizer」,是个假的 SKIP。 +# 故这里逐个构建、逐个跑,任何一个失败都判红。 csrc-sanitize: @echo "== C 侧 ASan+UBSan ==" @tmp=$$(mktemp -d); \ - if ! $(CC) $(CSRC_CFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer \ - -o $$tmp/san_test $(CSRC_SRCS) $(CSRC_DIR)/test/*.c 2>/dev/null; then \ - echo " [SKIP] 本机无 ASan/UBSan 运行库(交叉工具链常见),已跳过"; rm -rf $$tmp; exit 0; \ - fi; \ - if ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \ - $$tmp/san_test > $$tmp/out.txt 2>&1; then \ - echo " ASan+UBSan 契约测试: PASS"; rm -rf $$tmp; \ - else \ - echo " [FAIL] sanitizer 报告:"; cat $$tmp/out.txt | head -30; rm -rf $$tmp; exit 1; \ - fi + built=0; \ + for t in $(CSRC_DIR)/test/test_*.c; do \ + case "$$t" in *fuzz*) continue ;; esac; \ + base=$$(basename $$t .c); \ + if ! $(CC) $(CSRC_CFLAGS) -fsanitize=address,undefined -fno-omit-frame-pointer \ + -o $$tmp/$$base $(CSRC_SRCS) $$t 2>$$tmp/build.log; then \ + if grep -qi 'sanitize\|asan\|ubsan' $$tmp/build.log; then \ + echo " [SKIP] 本机无 ASan/UBSan 运行库,已跳过"; rm -rf $$tmp; exit 0; \ + fi; \ + echo " [FAIL] 构建失败 ($$base):" ; head -10 $$tmp/build.log; rm -rf $$tmp; exit 1; \ + fi; \ + built=1; \ + if ASAN_OPTIONS=detect_leaks=1 UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \ + $$tmp/$$base > $$tmp/$$base.out 2>&1; then \ + echo " ASan+UBSan $$base: PASS"; \ + else \ + echo " [FAIL] sanitizer 报告 ($$base):"; head -30 $$tmp/$$base.out; rm -rf $$tmp; exit 1; \ + fi; \ + done; \ + rm -rf $$tmp; \ + if [ "$$built" = "0" ]; then echo " [FAIL] 没找到任何契约测试"; exit 1; fi # csrc-headers:头文件自包含性(每个 .h 都能单独编过) # @@ -207,12 +222,16 @@ csrc-cross: if ! command -v $$CC_ARM64 >/dev/null 2>&1; then \ echo " [SKIP] $$CC_ARM64 不存在(未装交叉工具链)"; exit 0; \ fi; \ - if $$CC_ARM64 -std=$(CSRC_STD) $(CSRC_WARN_FLAGS) -Werror -I$(CSRC_DIR)/include \ - -c $(CSRC_SRCS) -o /dev/null 2>/dev/null; then \ - echo " $$CC_ARM64: 0 告警、编译通过 ✓"; \ + ok=1; \ + for src in $(CSRC_SRCS); do \ + if ! $$CC_ARM64 $(CSRC_CFLAGS) -Werror -fsyntax-only $$src 2>&1 | head -20; then \ + ok=0; \ + fi; \ + done; \ + if [ "$$ok" = "1" ]; then \ + echo " $$CC_ARM64: 0 告警、编译通过 ✓($(words $(CSRC_SRCS)) 个源文件)"; \ else \ - echo " [FAIL] arm64 交叉编译失败(把 .o 汇成单个输出是 gcc 的已知限制,改用逐文件)"; \ - $$CC_ARM64 $(CSRC_CFLAGS) -Werror -fsyntax-only $(CSRC_SRCS) 2>&1 | head -20; exit 1; \ + echo " [FAIL] arm64 交叉编译失败"; exit 1; \ fi # check-csrc:C 侧全部门禁的聚合入口(接进 make test 与 CI) diff --git a/csrc/CMakeLists.txt b/csrc/CMakeLists.txt index 389cc27..8a92d53 100644 --- a/csrc/CMakeLists.txt +++ b/csrc/CMakeLists.txt @@ -31,6 +31,7 @@ set(CMAKE_C_EXTENSIONS OFF) # 禁用 gnu99 扩展,严格 -std=c99 set(HA_CODEC_SRC src/ha_codec.c + src/ha_json_scan.c ) if(BUILD_SHARED_LIBS) @@ -71,8 +72,11 @@ install(DIRECTORY include/ DESTINATION include) if(BUILD_TESTS) add_executable(ha_codec_test test/test_ha_codec.c) target_link_libraries(ha_codec_test PRIVATE ha_codec) + add_executable(ha_json_scan_test test/test_ha_json_scan.c) + target_link_libraries(ha_json_scan_test PRIVATE ha_codec) enable_testing() add_test(NAME ha_codec_test COMMAND ha_codec_test) + add_test(NAME ha_json_scan_test COMMAND ha_json_scan_test) endif() # ============================================================ @@ -92,7 +96,7 @@ endif() # ============================================================ if(BUILD_FUZZ) if(CMAKE_C_COMPILER_ID MATCHES "Clang") - foreach(fz IN ITEMS test_fuzz_ha_codec) + foreach(fz IN ITEMS test_fuzz_ha_codec test_fuzz_ha_json_scan) add_executable(${fz} test/${fz}.c) target_link_libraries(${fz} PRIVATE ha_codec) target_compile_options(${fz} PRIVATE diff --git a/csrc/include/ha_json_scan.h b/csrc/include/ha_json_scan.h new file mode 100644 index 0000000..73cfa21 --- /dev/null +++ b/csrc/include/ha_json_scan.h @@ -0,0 +1,232 @@ +#ifndef HA_JSON_SCAN_H +#define HA_JSON_SCAN_H + +/* + * ha_json_scan — HomeAgent 内核 LLM 协议层的 JSON 扫描/取值层(C 实现) + * + * ============================ 定位 ============================ + * 本库**不是**通用 JSON 库,是**流式协议分块解析**专用的零分配扫描层。 + * 它服务 `parseOpenAICompatibleStreamChunkFull`(每个 SSE chunk 跑一次的最热路径)。 + * + * ★ 为什么不复用 SDK 的 remotedevice/src/ha_json.c(实测,见 plan.md §七): + * 1. 无 `\u` 解码 —— `\u4f60\u597d` 得到 `?0?d?d?0`(LLM 内容全靠转义时直接损坏) + * 2. 只有 `_get_int`,无浮点 —— `temperature:0.7` **静默**变 0 + * 3. `null` 与「键缺失」不可区分 + * 4. 架构是 **DOM + malloc**,与「不 malloc / 零拷贝 / 纯函数」正交 + * 它的定位是 remotedevice 设备通道,不是 LLM 协议层。 + * + * ============================ 设计:scan / extract 两段分离 ============================ + * **scan** 只出结构 span(键 span / 值 span),零分配、零解码、零求值。 + * **extract** 按 span 取值,解码只发生在真正需要它的调用方身上。 + * + * 为什么必须分离:`content` 可能是很大的多模态数组,而 `stringifyContent` + * 只需要「把 text 字段拼起来」。若 scan 阶段就为每个字符串 `\u` 解码并分配 + * 缓冲,等于把解码成本付给了不需要它的调用方 —— 那正是我们要消灭的分配。 + * + * ============================ 不可协商的约束(与 ha_codec.h 同标准) ============================ + * 1. 只吃 `const char*` + **显式长度**,不要求 NUL 结尾 + * (否则又是 `strlen` + 拷贝的老问题,见第一刀 §7.1 的 82% 自找开销) + * 2. **不 malloc**:结果一律以 span(指针+长度)回给调用方,Go 侧零拷贝切片 + * 3. 无状态、纯函数、线程安全(不写全局可变状态) + * 4. 语法语义必须与 Go `encoding/json` **一致**,由黄金对照测试钉死 + * + * ============================ 语义对齐(易踩,全部实测) ============================ + * - 键匹配**大小写不敏感**(Go `encoding/json` 行为) + * - 字符串取值时非法 UTF-8 每字节替换为 U+FFFD(与 Go 一致) + * - 重复键**后者胜** + * - 本层**不做类型检查**:`{"a":{}}` 对 `a` 的扫描成功,是否「类型不对应报错」 + * 由调用方按 Go 的 interface{} / 强类型语义决定(见 §三.2.2 的实测) + */ + +#include + +#include "ha_abi.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ==================== ABI 版本(与 ha_abi.h 同步) ==================== */ + +#define HA_JSON_SCAN_ABI_MAJOR 1 +#define HA_JSON_SCAN_ABI_MINOR 0 +#define HA_JSON_SCAN_ABI_VERSION \ + (HA_JSON_SCAN_ABI_MAJOR * 1000 + HA_JSON_SCAN_ABI_MINOR) + +HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MAJOR >= 1 && HA_JSON_SCAN_ABI_MAJOR <= 9, + ha_jsonscan_abi_major_in_range); +HA_STATIC_ASSERT(HA_JSON_SCAN_ABI_MINOR >= 0 && HA_JSON_SCAN_ABI_MINOR <= 99, + ha_jsonscan_abi_minor_in_range); + +/* 返回 HA_JSON_SCAN_ABI_VERSION(供 Go 侧与日志核对)。 */ +int ha_json_scan_abi_version(void); + +/* ==================== span 与扫描器 ==================== */ + +/* 字节区间 [p, p+len)。指针指向**调用方的原缓冲**,本库从不持有或释放。 */ +typedef struct { + const char *p; + size_t len; +} ha_span; + +/* 扫描器:对一段 JSON 文本的只读游标。 + * + * ★ 就地结构体(非指针):调用方在栈上持有,零分配。 + * 但因此**不可拷贝后混用**(拷贝出的副本与原游标各自独立推进)。 + */ +typedef struct { + const char *s; /* 缓冲区起点 */ + size_t n; /* 缓冲区长度 */ + size_t i; /* 当前游标偏移 */ +} ha_json_scan; + +/* 用 (s, n) 初始化扫描器,游标置于起点。s 可为 NULL(此时按 n=0 处理)。 */ +void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n); + +/* 跳过前导 ASCII 空白(空格 / \t / \n / \r)。返回是否已到结尾。 */ +int ha_json_scan_ws(ha_json_scan *sc); + +/* 当前是否已到结尾(不含空白跳过)。 */ +int ha_json_scan_eof(const ha_json_scan *sc); + +/* 跳过**一个完整的 JSON 值**(对象 / 数组 / 字符串 / 数字 / 字面量)。 + * + * 用于二次进数组内部(如 stringifyContent 取数组元素的 text 字段): + * 先 skip 前面的元素,再对目标元素单独扫描。 + * 返回 0 表示语法错误,1 表示成功。成功后游标停在该值之后。 + */ +int ha_json_skip(ha_json_scan *sc); + +/* 解析一个字符串值,出**原始字节 span**(含转义序列,未解码)。 + * + * 入参:游标应停在 `"` 上(或之前的空白,函数自己跳过空白)。 + * 出参 raw:不含两端引号的原始内容 span(指向原缓冲,零拷贝)。 + * 返回 0 = 语法错误(未闭合 / 非字符串)。 + * + * ★ 注意:不做 `\u` 解码、不做非法 UTF-8 替换 —— 那是 extract 阶段的事。 + */ +int ha_json_scan_string(ha_json_scan *sc, ha_span *raw); + +/* ==================== 顶层对象:扫描出键值对 ==================== */ + +/* + * 顶层对象的迭代器。 + * + * ★ 为什么由本库来切「顶层逗号」而不是让 C 侧只解析第一个键: + * LLM 的 `content` 里常含 `{`、`}`、`,`(代码、JSON 片段、模板)。 + * 若调用方自己按逗号切开顶层,会被内容里的逗号错切。 + * 本库扫**字符串感知**的边界,保证只在真正的顶层分隔符处切分。 + */ +typedef struct { + ha_json_scan sc; /* 游标 */ + int started; /* 是否已消费过至少一个成员 */ + int done; /* 迭代是否已结束(正常或异常) */ + int error; /* 结束原因:1 = 输入畸形(而非正常的 '}') */ +} ha_json_members; + +/* 初始化顶层对象迭代。非法(首个非空白字符不是 '{')时返回 0。 */ +int ha_json_members_init(ha_json_members *m, const char *s, size_t n); + +/* 取下一个成员。 + * + * 出参: + * key —— 键的原始字节 span(未解码,不含引号);可为 NULL + * val —— 值的**完整 span**(未解码);可为 NULL + * + * 返回: 1 = 拿到一个完整成员;0 = 结束。 + * + * ★ 本函数**内部会完整跳过一个值**,因此: + * 1. 返回 1 蕴含「这个成员是良构的」(值能独立被 skip)—— + * 调用方拿到的 val 一定可解析,不必自己再验一次。 + * 2. 游标在返回前已推进到值之后,下一次调用直接看下一个成员。 + * (早期版本只报值的**起始位置**、不消费值,迫使调用方自己 + * 修正游标 —— 那是个错误的设计:调用方一旦忘了推进,下一个 + * 成员就会解析到上一个值,而模糊测试立刻把它暴露了出来。) + * + * ★ 结束时要区分原因:用 ha_json_members_complete() 判断是否正常。 + * 返回 0 既可能是「正常扫到 '}'」也可能是「输入畸形」—— + * 要复刻 Go 的严格性(畸形 ⇒ 整块作废)就必须能分辨。 + * + * ★ 键匹配请用 ha_json_key_eq(大小写不敏感),不要自己 memcmp。 + */ +int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val); + +/* 迭代是否**正常结束**(消费到闭合的 '}')。 + * + * 语义:只有在 next() 返回 0 之后才有意义。 + * 返回 1 = 对象良构且已完整扫描;0 = 输入畸形(缺 '}' / 尾逗号 / + * 值非法等)。调用方若要复刻 Go 的严格性,应要求它为 1。 + */ +int ha_json_members_complete(const ha_json_members *m); + +/* 大小写不敏感地比较键 span 与 ASCII 字面量。返回 1/0。 + * + * ★ 必须用它而不是 memcmp:Go `encoding/json` 的键匹配**大小写不敏感**, + * 实测 `{"delta":{"CONTENT":"up"}}` 能取出 content="up"。 + * 逐字节比对会静默漏掉这类输入。 */ +int ha_json_key_eq(ha_span key, const char *name); + +/* ==================== 取值(extract) ==================== */ + +/* 字符串解码的**写入回调**。 + * + * ★ 为什么用回调而不是「分配缓冲返回」:本库不 malloc。调用方把自己的 + * Go 侧 buffer / 栈缓冲 / 直接写目标的位置交给本库,解码结果逐个 rune + * 以 UTF-8 字节写入 —— 非法序列按 Go 语义替换为 U+FFFD。 + * + * ★ 为什么按 rune 而不是按字节:`\uXXXX` 可能产生多字节 rune(含代理对 + * 合成的 4 字节 emoji),调用方不该关心编码细节。 + */ +typedef void (*ha_json_sink)(void *ctx, const char *utf8_bytes, size_t len); + +/* 把字符串值 span(raw 形式,含转义)解码并经 sink 输出。 + * + * 出参 out_len:解码后的字节总数(便于调用方预分配 / 校验)。 + * 返回 0 = 原始 span 含**语法错误**(如 \u 后不是 4 位十六进制)。 + * + * 非法 UTF-8 处理:与 Go `encoding/json` 一致 —— 每个非法字节一个 U+FFFD + * (**不是**按整个序列丢弃)。见真值表 §2.8。 + */ +int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx, + size_t *out_len); + +/* 把字符串值 span 解码进调用方提供的缓冲(不足则失败,不截断)。 + * + * 返回写入的字节数;缓冲不足时返回 (size_t)-1 且不写。 + * 适合长度已知且不关心「只需长度」的场景。 + */ +size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap); + +/* 读整数(仅接受 JSON 整数语法,可选负号;不允许小数点/指数)。 + * + * ★ 与 Go 的对应关系:Go 里 `int` 字段会接受 `1e2`(=100)与拒绝 `1.5`; + * 本函数**只认纯整数**,指数/小数由调用方按「类型不匹配 ⇒ 整块作废」 + * 语义处理(见真值表 §2.2)。这样职责清晰:本层只回答「这是不是整数」。 + * + * 返回 1 = 成功且 *out 已写;0 = 不是合法整数。 + * 溢出返回 0(与 Go 报错等价)。 + */ +int ha_json_get_int(ha_span raw, long long *out); + +/* ==================== 字符串取值的便捷路径 ==================== */ + +/* 在对象 span 内取键 name 的字符串值,解码进 out(NUL 结尾)。 + * + * 返回:解码后字节数(不含结尾 NUL);键缺失 / 类型不是字符串 / 缓冲不足 + * 返回 (size_t)-1。out 在成功时保证 NUL 结尾。 + * + * 便捷函数:内部走 members 迭代 + key_eq + decode,适合调用方只取一两个键 + * 且不需要「类型不匹配 ⇒ 整块作废」细节的场景。 + */ +size_t ha_json_object_get_string(ha_span obj, const char *name, + char *out, size_t out_cap); + +/* 在对象 span 内取键 name 的整数值。 + * 返回 1 = 成功;0 = 键缺失 / 非合法整数 / 溢出。 */ +int ha_json_object_get_int(ha_span obj, const char *name, long long *out); + +#ifdef __cplusplus +} +#endif + +#endif /* HA_JSON_SCAN_H */ diff --git a/csrc/src/ha_json_scan.c b/csrc/src/ha_json_scan.c new file mode 100644 index 0000000..7f3773f --- /dev/null +++ b/csrc/src/ha_json_scan.c @@ -0,0 +1,825 @@ +/* + * ha_json_scan.c — HomeAgent 内核 LLM 协议层 JSON 扫描/取值(C 实现) + * + * ============================ 性能设计(勿回退) ============================ + * 1. **不 malloc**:一切结果以 span 回传,Go 侧零拷贝切片 + * 2. **不 strlen**:长度由调用方传入 + * 3. **不预扫**:scan 只在需要时前进一步;「找键」靠 members 迭代单趟, + * 不先扫一遍收集全部键(那会缓存踩踏 + 二次遍历) + * 4. **整数不走 strtoll**:strtoll 要 NUL 结尾或处理 locale, + * 自写定点解析只认 JSON 整数语法,顺带把溢出判掉 + * 5. **字符串不建索引**:不记录转义位置。需要时按需解码 + * + * 参照第一刀的教训(docs/zh/c-core/llm-orchestration-c.md §7.1): + * 初版每次调用 C.CString(malloc+拷贝)+ C 侧 strlen,单这两项就吃掉 + * 82% 的时间 —— 那不是 cgo 的固有成本,是自找的。本库从设计上排除这类开销。 + * + * 语义必须与 Go `encoding/json` 一致,由黄金对照测试钉死 + * (真值表见 docs/zh/c-core/sse-codec-c.md §二)。 + */ + +#include "ha_json_scan.h" + +#include + +/* ---------------------------------------------------------------- */ +/* ABI 自述 */ +/* ---------------------------------------------------------------- */ + +int ha_json_scan_abi_version(void) { + return HA_JSON_SCAN_ABI_VERSION; +} + +/* ---------------------------------------------------------------- */ +/* 基础工具 */ +/* ---------------------------------------------------------------- */ + +/* JSON 空白:Go 的 encoding/json 只认这四个(不是 isspace)。 + * 差一个字符就会与 Go 分叉,故显式列举而非用 ctype。 */ +static int is_ws(unsigned char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; +} + +static unsigned char ascii_lower(unsigned char c) { + return (c >= 'A' && c <= 'Z') ? (unsigned char)(c + 32) : c; +} + +void ha_json_scan_init(ha_json_scan *sc, const char *s, size_t n) { + if (sc == NULL) { + return; + } + sc->s = (s != NULL) ? s : ""; + sc->n = (s != NULL) ? n : 0; + sc->i = 0; +} + +int ha_json_scan_ws(ha_json_scan *sc) { + if (sc == NULL) { + return 1; + } + while (sc->i < sc->n && is_ws((unsigned char)sc->s[sc->i])) { + sc->i++; + } + return (sc->i < sc->n) ? 0 : 1; +} + +int ha_json_scan_eof(const ha_json_scan *sc) { + if (sc == NULL) { + return 1; + } + return (sc->i >= sc->n) ? 1 : 0; +} + +/* 当前字符;到结尾返回 '\0'(0)。调用方需先判 eof。 */ +static char peek(const ha_json_scan *sc) { + return (sc->i < sc->n) ? sc->s[sc->i] : '\0'; +} + +/* 前进一字节;越界时不动(保持 eof 语义稳定)。 */ +static void bump(ha_json_scan *sc) { + if (sc->i < sc->n) { + sc->i++; + } +} + +static int expect(ha_json_scan *sc, char c) { + if (ha_json_scan_ws(sc) || peek(sc) != c) { + return 0; + } + bump(sc); + return 1; +} + +/* ---------------------------------------------------------------- */ +/* 值扫描(skip 一个完整值) */ +/* ---------------------------------------------------------------- */ + +static int scan_value(ha_json_scan *sc, int depth); +static int hex_val(unsigned char c); + +/* 扫描字符串(含引号),出原始内容 span。 + * depth 传入是因为 scan_value 会递归;字符串本身不递归但需要限额。 */ +static int scan_string_raw(ha_json_scan *sc, ha_span *raw, int depth) { + if (depth > 128) { + return 0; /* 深度保险,正常文档远小于此 */ + } + if (ha_json_scan_ws(sc) || peek(sc) != '"') { + return 0; + } + bump(sc); /* 开引号 */ + size_t start = sc->i; + while (sc->i < sc->n) { + char c = sc->s[sc->i]; + if (c == '"') { + if (raw != NULL) { + raw->p = sc->s + start; + raw->len = sc->i - start; + } + bump(sc); /* 闭引号 */ + return 1; + } + if (c == '\\') { + bump(sc); + if (sc->i >= sc->n) { + return 0; /* 末尾悬空反斜杠 */ + } + /* ★ 必须校验转义字符本身合法:Go 的 unquoteBytes 对未知转义 + * (\q、\x、单独 \p)返回错误 ⇒ 整个 Unmarshal 失败。 + * 初版只 bump 不校验,于是 `{"a":"\q"}` 被 C 判为合法, + * 而 json.Valid=false —— 黄金对照当场抓到。 + * (`\u` 的 4 位十六进制在解码阶段校验:那是**值**层面的 + * 错误,与扫描阶段的「转义序列形状」是两回事。) */ + char e = sc->s[sc->i]; + if (e != '"' && e != '\\' && e != '/' && e != 'b' && e != 'f' && + e != 'n' && e != 'r' && e != 't' && e != 'u') { + return 0; + } + /* ★ `\u` 必须紧跟 **4 位十六进制**,且这一校验属于**扫描**阶段: + * Go 的 json.Valid 会拒绝 `{"a":"\u00"}`(不足 4 位), + * 而初版把它留到解码阶段 ⇒ scan 判合法、json.Valid 判非法, + * 黄金对照当场抓到这条分叉。 + * 校验放在扫描阶段还有一个好处:畸形的 wire 数据在 + * 「找键」阶段就被拒,不必等到取值。 */ + if (e == 'u') { + /* 用 size_t 递推偏移,避免 int 与 size_t 混算 + * (-Wconversion/-Wsign-conversion 会拦下 sign-change)。 */ + if (sc->n - sc->i < 5u) { + return 0; /* 位数不足:还需 'u' 之后 4 位 */ + } + for (size_t k = 1; k <= 4u; k++) { + if (hex_val((unsigned char)sc->s[sc->i + k]) < 0) { + return 0; /* 非十六进制 */ + } + } + } + bump(sc); /* 被转义的字符;\u 的 4 位十六进制由上面的循环覆盖 */ + continue; + } + if ((unsigned char)c < 0x20) { + return 0; /* Go 拒绝字符串里的裸控制字符 */ + } + bump(sc); + } + return 0; /* 未闭合 */ +} + +/* 扫描字面量:true / false / null。 */ +static int scan_literal(ha_json_scan *sc) { + static const char kTrue[] = "true"; + static const char kFalse[] = "false"; + static const char kNull[] = "null"; + size_t rest = sc->n - sc->i; + const char *p = sc->s + sc->i; + + if (rest >= 4 && memcmp(p, kTrue, 4) == 0) { + sc->i += 4; + return 1; + } + if (rest >= 5 && memcmp(p, kFalse, 5) == 0) { + sc->i += 5; + return 1; + } + if (rest >= 4 && memcmp(p, kNull, 4) == 0) { + sc->i += 4; + return 1; + } + return 0; +} + +/* 数字:只校验**语法**(不求值)。求值由 ha_json_get_int / 调用方负责。 + * 这与 Go 的分工一致:Go 在 unmarshal 时求值并做范围检查, + * 而本层的取整数是独立的一步。 */ +static int scan_number(ha_json_scan *sc) { + size_t start = sc->i; + if (sc->i < sc->n && peek(sc) == '-') { + bump(sc); + } + /* 整数部分:0 或 [1-9][0-9]*(禁止前导零,与 Go 一致) */ + if (sc->i >= sc->n) { + return 0; + } + if (peek(sc) == '0') { + bump(sc); + } else if (peek(sc) >= '1' && peek(sc) <= '9') { + while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') { + bump(sc); + } + } else { + return 0; + } + /* 小数部分 */ + if (sc->i < sc->n && peek(sc) == '.') { + bump(sc); + if (sc->i >= sc->n || peek(sc) < '0' || peek(sc) > '9') { + return 0; /* "1." 与 "1.e3" 非法 */ + } + while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') { + bump(sc); + } + } + /* 指数部分 */ + if (sc->i < sc->n && (peek(sc) == 'e' || peek(sc) == 'E')) { + bump(sc); + if (sc->i < sc->n && (peek(sc) == '+' || peek(sc) == '-')) { + bump(sc); + } + if (sc->i >= sc->n || peek(sc) < '0' || peek(sc) > '9') { + return 0; + } + while (sc->i < sc->n && peek(sc) >= '0' && peek(sc) <= '9') { + bump(sc); + } + } + return (sc->i > start) ? 1 : 0; +} + +/* 扫描数组/对象。用显式 depth 递归(不用堆栈,零分配)。 */ +static int scan_container(ha_json_scan *sc, char open, char close, int depth) { + if (!expect(sc, open)) { + return 0; + } + if (ha_json_scan_ws(sc)) { + return 0; /* 未闭合 */ + } + if (peek(sc) == close) { + bump(sc); + return 1; /* 空容器 */ + } + for (;;) { + if (open == '{') { + ha_span k; + if (!scan_string_raw(sc, &k, depth + 1)) { + return 0; + } + if (!expect(sc, ':')) { + return 0; + } + } + if (!scan_value(sc, depth + 1)) { + return 0; + } + if (ha_json_scan_ws(sc)) { + return 0; + } + if (peek(sc) == ',') { + bump(sc); + continue; + } + if (peek(sc) == close) { + bump(sc); + return 1; + } + return 0; /* 缺 '}' 或多余的 ',' 之后没有键 */ + } +} + +static int scan_value(ha_json_scan *sc, int depth) { + if (depth > 128) { + return 0; + } + if (ha_json_scan_ws(sc)) { + return 0; + } + char c = peek(sc); + switch (c) { + case '{': return scan_container(sc, '{', '}', depth); + case '[': return scan_container(sc, '[', ']', depth); + case '"': { + ha_span tmp; + return scan_string_raw(sc, &tmp, depth); + } + case 't': case 'f': case 'n': return scan_literal(sc); + default: + if (c == '-' || (c >= '0' && c <= '9')) { + return scan_number(sc); + } + return 0; + } +} + +int ha_json_skip(ha_json_scan *sc) { + if (sc == NULL) { + return 0; + } + return scan_value(sc, 0); +} + +int ha_json_scan_string(ha_json_scan *sc, ha_span *raw) { + if (sc == NULL) { + return 0; + } + return scan_string_raw(sc, raw, 0); +} + +/* ---------------------------------------------------------------- */ +/* 顶层对象成员迭代 */ +/* ---------------------------------------------------------------- */ + +int ha_json_members_init(ha_json_members *m, const char *s, size_t n) { + if (m == NULL) { + return 0; + } + ha_json_scan_init(&m->sc, s, n); + m->started = 0; + m->done = 0; + m->error = 0; + if (ha_json_scan_ws(&m->sc) || peek(&m->sc) != '{') { + return 0; + } + bump(&m->sc); + return 1; +} + +int ha_json_members_next(ha_json_members *m, ha_span *key, ha_span *val) { + if (m == NULL || m->done) { + return 0; + } + if (ha_json_scan_ws(&m->sc)) { + m->done = 1; + m->error = 1; /* 未闭合 */ + return 0; + } + if (peek(&m->sc) == '}') { + bump(&m->sc); + m->done = 1; + m->error = 0; /* 正常结束 */ + return 0; /* 没有更多成员 */ + } + /* ★ 不接受尾逗号:Go 的 decoder 在 ',' 之后要求必有下一个键。 + * `{"a":1,}` 在 Go 侧是语法错误,故这里也必须拒绝。 */ + if (m->started) { + if (peek(&m->sc) != ',') { + m->done = 1; + m->error = 1; + return 0; + } + bump(&m->sc); + if (ha_json_scan_ws(&m->sc)) { + m->done = 1; + m->error = 1; + return 0; + } + if (peek(&m->sc) == '}') { + m->done = 1; + m->error = 1; /* 尾逗号 */ + return 0; + } + } + + ha_span k; + if (!scan_string_raw(&m->sc, &k, 0)) { + m->done = 1; + m->error = 1; + return 0; + } + if (!expect(&m->sc, ':')) { + m->done = 1; + m->error = 1; + return 0; + } + if (ha_json_scan_ws(&m->sc)) { + m->done = 1; + m->error = 1; + return 0; + } + + /* ★ 就地完整跳过一个值,得到它的精确 span。 + * 这样「返回 1」就蕴含「该成员良构」,且游标已推进到值之后。 */ + size_t vstart = m->sc.i; + if (!scan_value(&m->sc, 0)) { + m->done = 1; + m->error = 1; + return 0; + } + size_t vend = m->sc.i; + + m->started = 1; + if (key != NULL) { + *key = k; + } + if (val != NULL) { + val->p = m->sc.s + vstart; + val->len = vend - vstart; + } + return 1; +} + +int ha_json_members_complete(const ha_json_members *m) { + if (m == NULL) { + return 0; + } + return (m->done && !m->error) ? 1 : 0; +} + +int ha_json_key_eq(ha_span key, const char *name) { + if (name == NULL) { + return 0; + } + size_t nl = 0; + while (name[nl] != '\0') { + nl++; + } + if (key.len != nl) { + return 0; + } + for (size_t i = 0; i < nl; i++) { + if (ascii_lower((unsigned char)key.p[i]) != + ascii_lower((unsigned char)name[i])) { + return 0; + } + } + return 1; +} + +/* ---------------------------------------------------------------- */ +/* 字符串解码 */ +/* ---------------------------------------------------------------- */ + +/* U+FFFD 的 UTF-8 编码(Go 对非法字节的替换目标)。 */ +static const char kReplacement[3] = { (char)0xEF, (char)0xBF, (char)0xBD }; + +/* 十六进制值;非十六进制返回 -1。 */ +static int hex_val(unsigned char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +/* 把码点编码成 UTF-8 写给 sink。返回写入字节数。 */ +static size_t emit_rune(unsigned long cp, ha_json_sink sink, void *ctx) { + unsigned char buf[4]; + size_t len; + if (cp < 0x80) { + buf[0] = (unsigned char)cp; + len = 1; + } else if (cp < 0x800) { + buf[0] = (unsigned char)(0xC0 | (cp >> 6)); + buf[1] = (unsigned char)(0x80 | (cp & 0x3F)); + len = 2; + } else if (cp < 0x10000) { + buf[0] = (unsigned char)(0xE0 | (cp >> 12)); + buf[1] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + buf[2] = (unsigned char)(0x80 | (cp & 0x3F)); + len = 3; + } else { + buf[0] = (unsigned char)(0xF0 | (cp >> 18)); + buf[1] = (unsigned char)(0x80 | ((cp >> 12) & 0x3F)); + buf[2] = (unsigned char)(0x80 | ((cp >> 6) & 0x3F)); + buf[3] = (unsigned char)(0x80 | (cp & 0x3F)); + len = 4; + } + sink(ctx, (const char *)buf, len); + return len; +} + +/* 解码一段 raw(已定位转义与续字节的边界)。 + * + * 非法 UTF-8 语义必须与 Go 逐字节一致: + * Go 的 unquoteBytes 遇到非法序列时,把**能构成前缀的最长合法部分**先解出, + * 再对**第一个坏字节**产出单个 U+FFFD,然后从坏字节**之后**继续。 + * 即:一个坏字节 = 一个 U+FFFD(不是整个序列变一个)。 + * 典型:`\xff\xfe` → 两个 U+FFFD(真值表 §2.8 实测确认)。 + */ +static size_t decode_body(ha_span raw, ha_json_sink sink, void *ctx, int *err) { + size_t out = 0; + size_t i = 0; + *err = 0; + + while (i < raw.len) { + unsigned char c = (unsigned char)raw.p[i]; + + /* --- 转义 --- */ + if (c == '\\') { + if (i + 1 >= raw.len) { + *err = 1; + return out; + } + unsigned char e = (unsigned char)raw.p[i + 1]; + switch (e) { + case '"': sink(ctx, "\"", 1); out += 1; i += 2; continue; + case '\\': sink(ctx, "\\", 1); out += 1; i += 2; continue; + case '/': sink(ctx, "/", 1); out += 1; i += 2; continue; + case 'b': sink(ctx, "\b", 1); out += 1; i += 2; continue; + case 'f': sink(ctx, "\f", 1); out += 1; i += 2; continue; + case 'n': sink(ctx, "\n", 1); out += 1; i += 2; continue; + case 'r': sink(ctx, "\r", 1); out += 1; i += 2; continue; + case 't': sink(ctx, "\t", 1); out += 1; i += 2; continue; + case 'u': { + /* 需要 4 位十六进制:i+2 .. i+5 */ + if (i + 6 > raw.len) { + *err = 1; + return out; + } + int h0 = hex_val((unsigned char)raw.p[i + 2]); + int h1 = hex_val((unsigned char)raw.p[i + 3]); + int h2 = hex_val((unsigned char)raw.p[i + 4]); + int h3 = hex_val((unsigned char)raw.p[i + 5]); + if (h0 < 0 || h1 < 0 || h2 < 0 || h3 < 0) { + *err = 1; + return out; + } + unsigned long cp = (unsigned long)((h0 << 12) | (h1 << 8) | + (h2 << 4) | h3); + size_t adv = 6; + if (cp >= 0xD800 && cp <= 0xDBFF) { + /* 高代理:尝试与紧随的 \uDC00-\uDFFF 合成 4 字节 rune。 + * + * ★ 必须用 combined 标志,而不是「合成成功就直接落到底部」: + * 本块末尾有一段**无条件的** replacement 发射(处理合成 + * 失败的情形)。若成功的分支只设 cp/adv 而不跳过那一段, + * 会先把合成好的码点丢掉、再发一个 U+FFFD —— + * 实测症状:`\ud83d\ude00`(😀)得到 `\xef\xbf\xbd\xef\xbf\xbd`。 + * 这个 bug 只有**真的代理对**才会触发(`\u4f60` 这类 + * 非代理码点根本不进本块),是黄金对照最容易漏的一类。 + * + * 下界必须是 'i + 6 < raw.len'(而非一次判 i+12 <= len): + * 后者会连带拒绝「合法高代理位于字符串末尾」的正确输入。 */ + int combined = 0; + if (i + 6 < raw.len && raw.p[i + 6] == '\\' && + raw.p[i + 7] == 'u') { + int g0 = hex_val((unsigned char)raw.p[i + 8]); + int g1 = hex_val((unsigned char)raw.p[i + 9]); + int g2 = hex_val((unsigned char)raw.p[i + 10]); + int g3 = hex_val((unsigned char)raw.p[i + 11]); + if (g0 >= 0 && g1 >= 0 && g2 >= 0 && g3 >= 0) { + unsigned long lo = (unsigned long)( + (g0 << 12) | (g1 << 8) | (g2 << 4) | g3); + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000UL + ((cp - 0xD800UL) << 10) + + (lo - 0xDC00UL); + adv = 12; + combined = 1; + } + } + } + if (!combined) { + /* 高代理后面不是合法低代理:发一个 U+FFFD, + * 只消费掉这个 6 字节 \uXXXX,让后面的内容按原样 + * 继续解析(与 Go unquote 的行为一致)。 */ + sink(ctx, kReplacement, 3); + out += 3; + i += adv; + continue; + } + } + if (cp >= 0xDC00 && cp <= 0xDFFF) { + /* 孤立低代理 → U+FFFD */ + sink(ctx, kReplacement, 3); + out += 3; + i += 6; + continue; + } + out += emit_rune(cp, sink, ctx); + i += adv; + continue; + } + default: + /* Go 对未知转义(如 \q)报错 */ + *err = 1; + return out; + } + } + + /* --- 普通字节 / 多字节序列 --- */ + if (c < 0x80) { + char ch = (char)c; + sink(ctx, &ch, 1); + out += 1; + i++; + continue; + } + + /* 尝试解析一个合法多字节序列。 + * + * ★ 过长编码(overlong)检查**必须在续字节全部并入之后**做。 + * 初版把它写在这里、只用首字节的 cp: + * else if ((b0 & 0xF0) == 0xE0) { need = 3; cp = b0 & 0x0Fu; } + * if (need == 3 && cp < 0x800) valid = 0; // ← 此时 cp 只有首字节的位 + * 而 0xE4 恰好满足 0x0F 掩码 ⇒ cp = 4 ⇒ 4 < 0x800 ⇒ 误判非法 + * ⇒ 正常的「你」(e4 bd a0)被逐字节换成 6 个 U+FFFD(实测症状)。 + * 过长的真实判据是「完整码点 < 该长度的最小值」, + * 即 0xC0/0x80、0xE0 0x80、0xF0 0x80/0x90 这几类前缀。 */ + size_t need; + unsigned long cp; + unsigned char b0 = c; + if ((b0 & 0xE0) == 0xC0) { need = 2; cp = b0 & 0x1Fu; } + else if ((b0 & 0xF0) == 0xE0) { need = 3; cp = b0 & 0x0Fu; } + else if ((b0 & 0xF8) == 0xF0) { need = 4; cp = b0 & 0x07u; } + else { need = 0; cp = 0; } + + int valid = (need != 0); + if (valid) { + for (size_t k = 1; k < need; k++) { + if (i + k >= raw.len) { valid = 0; break; } + unsigned char nb = (unsigned char)raw.p[i + k]; + if ((nb & 0xC0) != 0x80) { valid = 0; break; } + cp = (cp << 6) | (unsigned long)(nb & 0x3F); + } + } + if (valid) { + /* 过长编码:按**完整码点**比该长度的最小合法值 + * (2B:0x80 / 3B:0x800 / 4B:0x10000) */ + if (need == 2 && cp < 0x80) valid = 0; + if (need == 3 && cp < 0x800) valid = 0; + if (need == 4 && cp < 0x10000) valid = 0; + /* 代理区编码(CESU-8 / WTF-8)Go 判非法 */ + if (cp >= 0xD800 && cp <= 0xDFFF) valid = 0; + if (cp > 0x10FFFF) valid = 0; + } + if (valid) { + sink(ctx, raw.p + i, need); + out += need; + i += need; + continue; + } + + /* 非法:单个字节 → 一个 U+FFFD,然后继续(与 Go 逐字节一致) */ + sink(ctx, kReplacement, 3); + out += 3; + i++; + } + return out; +} + +int ha_json_decode_string(ha_span raw, ha_json_sink sink, void *ctx, + size_t *out_len) { + if (sink == NULL) { + return 0; + } + int err = 0; + size_t n = decode_body(raw, sink, ctx, &err); + if (out_len != NULL) { + *out_len = n; + } + return err ? 0 : 1; +} + +/* ---------------------------------------------------------------- */ +/* 写入缓冲的 sink */ +/* ---------------------------------------------------------------- */ + +typedef struct { + char *out; + size_t cap; + size_t len; +} buf_sink; + +static void buf_write(void *ctx, const char *b, size_t n) { + buf_sink *s = (buf_sink *)ctx; + /* 缓冲不足时,**绝不再往后写**,并标记溢出(len > cap 即可辨认)。 + * + * ★ 契约是「不越界写」,不是「一个字节都不写」:本函数是流式的, + * 写到这里才知道放不下,之前已写出的部分无法撤销。 + * 调用方拿到 (size_t)-1 时**必须丢弃整个结果**(Go 侧就是这么做的)。 + * 若真需要 all-or-nothing,调用方应先测得长度再分配(两趟)。 + * 这个取舍是有意的:单趟更快,而丢弃结果对调用方是廉价的。 */ + if (s->len + n > s->cap) { + s->len = s->cap + 1; /* 标记溢出 */ + return; + } + memcpy(s->out + s->len, b, n); + s->len += n; +} + +size_t ha_json_decode_string_into(ha_span raw, char *out, size_t out_cap) { + if (out == NULL || out_cap == 0) { + return (size_t)-1; + } + buf_sink s; + s.out = out; + s.cap = out_cap - 1; /* 留一位给结尾 NUL */ + s.len = 0; + int err = 0; + (void)decode_body(raw, buf_write, &s, &err); + if (err || s.len > s.cap) { + return (size_t)-1; + } + out[s.len] = '\0'; + return s.len; +} + +/* ---------------------------------------------------------------- */ +/* 整数 */ +/* ---------------------------------------------------------------- */ + +int ha_json_get_int(ha_span raw, long long *out) { + if (raw.len == 0 || out == NULL) { + return 0; + } + size_t i = 0; + int neg = 0; + if (raw.p[0] == '-') { + neg = 1; + i = 1; + if (raw.len == 1) { + return 0; + } + } + /* 只接受 **JSON 整数语法**:可选 '-' + (0 | [1-9][0-9]*)。 + * 小数点 / 指数一律判「不是整数」,由调用方按 Go 的 + * 「类型不匹配 ⇒ 整块作废」语义处理。 + * + * ★ 必须禁前导零:JSON 里 `007` / `00` 是**非法数字**, + * 而 strconv.ParseInt 会接受它。若这里跟着接受, + * 就会出现「C 认得、json.Unmarshal 报错」的分叉 —— + * 黄金对照当场抓到(实测分歧:"007"、"00")。 + * 本层的职责是回答「这是不是 JSON 整数」,不是「能不能转成数字」。 */ + if (raw.p[i] == '0' && raw.len - i > 1) { + return 0; /* 前导零:00 / 01 / 007 均非法 */ + } + for (size_t k = i; k < raw.len; k++) { + if (raw.p[k] < '0' || raw.p[k] > '9') { + return 0; + } + } + unsigned long long acc = 0; + const unsigned long long limit = + neg ? 9223372036854775807ULL + 1ULL : 9223372036854775807ULL; + for (size_t k = i; k < raw.len; k++) { + unsigned d = (unsigned)(raw.p[k] - '0'); + if (acc > (limit - d) / 10ULL) { + return 0; /* 溢出(与 Go 报错等价) */ + } + acc = acc * 10ULL + d; + } + if (neg) { + *out = (acc == 9223372036854775808ULL) + ? (-9223372036854775807LL - 1) + : -(long long)acc; + } else { + *out = (long long)acc; + } + return 1; +} + +/* ---------------------------------------------------------------- */ +/* 便捷取值 */ +/* ---------------------------------------------------------------- */ + +/* 在对象里定位键 name 的值 span。 + * + * 找到返回 1 且 *val 覆盖该值的原始字节(未解码);未找到 / 语法错返回 0。 + * 重复键取**最后一次**(与 Go 的后者胜一致)。 + * + * 实现要点:members 迭代器只报「值的起始位置」,值本身由本函数用 + * ha_json_skip 消费并算出 span —— 这样两种便捷取值共用同一套定位逻辑, + * 不会因各自实现而分叉。 */ +static int find_value(ha_span obj, const char *name, ha_span *val) { + ha_json_members m; + if (!ha_json_members_init(&m, obj.p, obj.len)) { + return 0; + } + ha_span key; + ha_span v; + int found = 0; + ha_span last = { NULL, 0 }; + + while (ha_json_members_next(&m, &key, &v)) { + if (ha_json_key_eq(key, name)) { + last = v; + found = 1; /* 重复键后者胜:继续扫,只保留最后一次 */ + } + } + /* ★ 严格性:畸形输入必须判「找不到键」—— + * Go 侧语法错误会让 json.Unmarshal 失败、整块作废, + * 若这里放宽成「扫到哪算哪」,就会比 Go 宽松(见真值表 §2.2)。 */ + if (!ha_json_members_complete(&m)) { + return 0; + } + if (found && val != NULL) { + *val = last; + } + return found; +} + +size_t ha_json_object_get_string(ha_span obj, const char *name, + char *out, size_t out_cap) { + if (out == NULL || out_cap == 0) { + return (size_t)-1; + } + ha_span val; + if (!find_value(obj, name, &val)) { + return (size_t)-1; + } + /* 只接受字符串值;其他类型视为「取不到」(类型判断由调用方按 + * Go 的 interface{}/强类型语义决定,见 sse-codec-c.md §2.2) */ + ha_json_scan sc; + ha_json_scan_init(&sc, val.p, val.len); + ha_span raw; + if (!ha_json_scan_string(&sc, &raw)) { + return (size_t)-1; + } + return ha_json_decode_string_into(raw, out, out_cap); +} + +int ha_json_object_get_int(ha_span obj, const char *name, long long *out) { + if (out == NULL) { + return 0; + } + ha_span val; + if (!find_value(obj, name, &val)) { + return 0; + } + return ha_json_get_int(val, out); +} diff --git a/csrc/test/test_fuzz_ha_json_scan.c b/csrc/test/test_fuzz_ha_json_scan.c new file mode 100644 index 0000000..4e97539 --- /dev/null +++ b/csrc/test/test_fuzz_ha_json_scan.c @@ -0,0 +1,215 @@ +/* + * test_fuzz_ha_json_scan.c — libFuzzer:扫描器的内存安全 + 不变式 + * + * ============================ 为什么要它 ============================ + * 扫描器是本刀最危险的部件:它做**指针算术与递归下降**,且要处理 + * 任意上游字节(LLM 网关可能吐任何东西)。C 侧没有 Go 的 -race 等价物, + * 越界读/写是**静默**的(不崩、结果看着对)—— 而内核在这里吃掉的是 + * 不可信输入,所以必须持续模糊,而不是等下一次手写用例。 + * + * 覆盖的六条不变式: + * 1. scan/skip 的游标**永不越过**输入长度(否则后续所有 span 都错位) + * 2. skip 成功 ⇒ 恰好消费一个完整值,不残留结构字符 + * 3. 成员迭代器游标单调,且永不越过输入长度 + * 4. 解码输出的长度上界 = 输入长度的 3 倍 + * (每个字节最坏变一个 U+FFFD = 3 字节;这是内存规划的前提) + * 5. decode_into **绝不越界写** + * 6. get_int 与 strtoll 语义在合法整数上一致(溢出时都必须拒绝) + * + * 构建:cmake -DBUILD_FUZZ=ON(需 clang) + */ + +#include +#include +#include +#include +#include +#include + +#include "ha_json_scan.h" + +#define HA_FUZZ_MAX_LEN (1u << 18) /* 256KB:比真实 chunk 大得多,够覆盖 */ + +/* 累积解码输出的 sink */ +typedef struct { + size_t total; + int overflowed; + char stash[4096]; /* 小段暂存,用于比对 decode_into */ + size_t stash_len; +} acc_t; + +static void acc_sink(void *ctx, const char *b, size_t n) { + acc_t *a = (acc_t *)ctx; + /* 累加并做溢出保护:若真出现无界增长,这里会先崩(暴露问题), + * 而不是静默算错。 */ + if (a->total > (1ull << 40)) { + a->overflowed = 1; + } + a->total += n; + if (a->stash_len + n <= sizeof(a->stash)) { + memcpy(a->stash + a->stash_len, b, n); + a->stash_len += n; + } +} + +int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size); + +int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { + if (Size > HA_FUZZ_MAX_LEN) { + return 0; + } + const char *s = (const char *)Data; + + /* ---- 1. skip 游标边界 ---- */ + ha_json_scan sc; + ha_json_scan_init(&sc, s, Size); + int ok = ha_json_skip(&sc); + if (sc.i > Size) { + abort(); /* 游标越界 —— 后续所有 span 都会错位 */ + } + + /* ---- 2. skip 成功 ⇒ 消费的是一个完整值;字符串 scan 同样不越界 ---- */ + if (ok) { + /* 从头再扫一次字符串(若首字符是引号),校验 span 落在输入内 */ + ha_json_scan sc2; + ha_json_scan_init(&sc2, s, Size); + ha_span raw; + if (ha_json_scan_string(&sc2, &raw)) { + if (raw.len > Size) { + abort(); + } + /* span 必须落在输入区间内 */ + if (raw.p < s || raw.p > s + Size) { + abort(); + } + } + if (sc2.i > Size) { + abort(); + } + } + + /* ---- 3. 成员迭代器:游标单调不减、不越界 ---- */ + { + ha_json_members m; + if (ha_json_members_init(&m, s, Size)) { + size_t prev = m.sc.i; + ha_span key, val; + int guard = 0; + while (ha_json_members_next(&m, &key, &val)) { + if (m.sc.i > Size) { + abort(); + } + if (m.sc.i < prev) { + abort(); /* 游标回退 ⇒ 可能死循环 */ + } + prev = m.sc.i; + if (key.len > Size || key.p < s || key.p > s + Size) { + abort(); + } + /* ★ 值必须能独立跳过:这条不变式正是模糊测试第一轮 + * 抓到的缺陷(旧 API 只报值起点、不消费值,游标仍在 + * 值的前面,于是下一个成员解析到了值本身)。 */ + ha_json_scan vs; + ha_json_scan_init(&vs, val.p, val.len); + if (!ha_json_skip(&vs)) { + abort(); /* 成员报了个值,却跳不过去 ⇒ 内部不一致 */ + } + if (val.len > Size || val.p < s || val.p > s + Size) { + abort(); + } + if (++guard > 100000) { + abort(); /* 死循环保护 */ + } + } + /* 游标必须落在输入内 */ + if (m.sc.i > Size) { + abort(); + } + if (m.sc.i > Size) { + abort(); + } + } + } + + /* ---- 4/5. 解码:输出上界 + 不越界写 ---- + * 上界 3×:每个输入字节最坏变一个 3 字节 U+FFFD。 + * 若违反,说明解码器会放大数据 —— 那是内存放大的安全隐患。 */ + { + ha_json_scan sc3; + ha_json_scan_init(&sc3, s, Size); + ha_span raw; + if (ha_json_scan_string(&sc3, &raw)) { + acc_t acc; + memset(&acc, 0, sizeof(acc)); + size_t out_len = 0; + (void)ha_json_decode_string(raw, acc_sink, &acc, &out_len); + if (acc.total != out_len) { + abort(); /* sink 累加必须等于报告的 out_len */ + } + if (acc.total > (size_t)raw.len * 3 + 3) { + abort(); /* 放大超过 3× 上界 */ + } + + /* decode_into 用小缓冲:绝不越界(哨兵检查) */ + char tiny[8]; + memset(tiny, 0x5a, sizeof(tiny)); + size_t got = ha_json_decode_string_into(raw, tiny, sizeof(tiny)); + /* 成功时必须以 NUL 结尾且长度 < cap */ + if (got != (size_t)-1) { + if (got >= sizeof(tiny)) { + abort(); + } + if (tiny[got] != '\0') { + abort(); + } + } else { + /* 失败:末尾 NUL 位不得被单独改写(仍是哨兵或已被部分写)*/ + /* 只要求不越界 —— ASan 已保证,这里做一个显式触摸 */ + (void)tiny[sizeof(tiny) - 1]; + } + } + } + + /* ---- 6. get_int 与 strtoll 对照(合法整数) ---- */ + { + ha_span v = { s, Size }; + long long got = 0; + if (ha_json_get_int(v, &got)) { + /* 本库认了 ⇒ 必须是纯整数,且 strtoll 应给出同值 */ + char *dup = (char *)malloc(Size + 1); + if (dup) { + memcpy(dup, s, Size); + dup[Size] = '\0'; + errno = 0; + char *end = NULL; + long long ref = strtoll(dup, &end, 10); + /* 只有「整串被消费且无溢出」时才可比较 */ + if (errno == 0 && end == dup + Size) { + if (ref != got) { + abort(); /* 与 strtoll 分叉 */ + } + } + free(dup); + } + } + } + + /* ---- NULL / 空输入防御 ---- */ + if (Size == 0) { + ha_json_scan z; + ha_json_scan_init(&z, NULL, 0); + if (!ha_json_scan_eof(&z)) { + abort(); + } + if (ha_json_skip(&z)) { + abort(); + } + long long v; + ha_span e = { NULL, 0 }; + if (ha_json_get_int(e, &v)) { + abort(); + } + } + + return 0; +} diff --git a/csrc/test/test_ha_json_scan.c b/csrc/test/test_ha_json_scan.c new file mode 100644 index 0000000..6380a87 --- /dev/null +++ b/csrc/test/test_ha_json_scan.c @@ -0,0 +1,414 @@ +/* + * test_ha_json_scan.c — ha_json_scan 的 C 侧契约测试 + * + * 覆盖重点(与 docs/zh/c-core/sse-codec-c.md 真值表对应): + * 语法严格性、键大小写不敏感、重复键后者胜、\u 解码(含代理对)、 + * 非法 UTF-8 → U+FFFD、整数溢出、深度保险。 + * + * 另一半验收在 Go 侧(codec_jsongolden_test.go):与 encoding/json 逐值比对。 + * 本文件负责**不依赖 Go** 的语义自洽与边界安全。 + */ + +#include +#include +#include + +#include "ha_json_scan.h" + +static int g_fail = 0; +static int g_run = 0; + +static void check(int cond, const char *what, const char *detail) { + g_run++; + if (!cond) { + g_fail++; + printf(" [FAIL] %s%s%s\n", what, + detail ? " :: " : "", detail ? detail : ""); + } +} + +static void check_str(const char *what, const char *got, size_t gotlen, + const char *want) { + g_run++; + size_t wl = strlen(want); + if (wl != gotlen || memcmp(got, want, wl) != 0) { + g_fail++; + printf(" [FAIL] %s: got \"%.*s\" want \"%s\"\n", what, + (int)gotlen, got, want); + } +} + +/* ---------------- 语法严格性 ---------------- */ + +/* 约定:ok=1 表示「skip 成功」;ok=0 表示「拒绝」。 + * ★ 注意 `{"a":1}x` 在 skip 层**不拒绝**(skip 只跳一个值), + * 而「尾部有残留」的判定是**调用方的义务**(比对游标是否到末尾)。 + * 这与 Go 侧 json.Unmarshal 的区别就在这:Unmarshal 会拒绝尾部残留。 + * 故下面用 eoc(end-of-consume)字段单独断言。 */ +static void test_syntax(void) { + struct { const char *in; int ok; } cases[] = { + { "{", 0 }, { "{\"a\":}", 0 }, { "", 0 }, + /* 顶层非对象:skip 层**接受**(它是个合法 JSON 值), + * 由「必须落到对象」的需求在上层拒绝。Go 侧拒绝是因为要 Unmarshal + * 进 struct,与 skip 语义不同层。 */ + { "null", 1 }, { "[]", 1 }, { "\"str\"", 1 }, { "123", 1 }, + { "{\"a\":1,}", 0 }, /* 尾逗号非法 */ + { "{'a':1}", 0 }, /* 单引号非法 */ + { "{\"a\":1", 0 }, /* 未闭合 */ + { "{\"a\" 1}", 0 }, /* 缺冒号 */ + { "{\"a\":01}", 0 }, /* 前导零 */ + { "{\"a\":1.}", 0 }, /* 1. 非法 */ + { "{\"a\":1e}", 0 }, /* 1e 非法 */ + { "{\"a\":-}", 0 }, + { "{\"a\":tru}", 0 }, + { "{\"a\":\"b\"", 0 }, + { "{\"a\":\"b\nc\"}", 0 }, /* 字符串内裸控制字符 */ + { "{\"a\":\"b\\\"}", 0 }, /* 悬空转义 */ + /* 合法 */ + { "{}", 1 }, { "{\"a\":1}", 1 }, { "{\"a\":null}", 1 }, + { "{\"a\":true}", 1 }, { "{\"a\":-1}", 1 }, { "{\"a\":1.5}", 1 }, + { "{\"a\":1e2}", 1 }, { " {\"a\" : 1 } ", 1 }, + { "{\"a\":\"\\u4f60\"}", 1 }, { "{\"a\":{\"b\":[1,2]}}", 1 }, + { "{\"a\":[],\"b\":{}}", 1 }, + }; + for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) { + ha_json_scan sc; + ha_json_scan_init(&sc, cases[i].in, strlen(cases[i].in)); + int ok = ha_json_skip(&sc); + check(ok == cases[i].ok, "syntax", cases[i].in); + } + + /* 尾部残留:skip 不管,但调用方必须能察觉(比对游标) */ + { + const char *s = "{\"a\":1}x"; + ha_json_scan sc; + ha_json_scan_init(&sc, s, strlen(s)); + check(ha_json_skip(&sc) == 1, "trailing-garbage-skip-ok", s); + check(sc.i != sc.n, "trailing-garbage-detectable", s); + } + /* 前后空白:必须被吃掉,调用方才能用 i==n 判定「干净」 */ + { + const char *s = " {\"a\" : 1 } "; + ha_json_scan sc; + ha_json_scan_init(&sc, s, strlen(s)); + check(ha_json_skip(&sc) == 1, "ws-skip-ok", s); + /* 尾部空白是 JSON 允许的:**不能**要求游标精确落在 n。 + * 真正要保证的是「值本体已被完整消费」—— + * 即剩余部分只剩空白。这个判定留给调用方(见 sse-codec-c.md §2.5)。 */ + int rest_is_ws = 1; + for (size_t k = sc.i; k < sc.n; k++) { + if (s[k] != ' ' && s[k] != '\t' && s[k] != '\n' && s[k] != '\r') { + rest_is_ws = 0; + } + } + check(rest_is_ws, "ws-tail-only-whitespace", s); + } +} + +/* ---------------- 顶层成员迭代 / 大小写不敏感 ---------------- */ + +static void test_members(void) { + /* Go 的键匹配大小写不敏感:{"DELTA":{"CONTENT":"up"}} */ + const char *s = "{\"DELTA\":{\"CONTENT\":\"up\"}}"; + ha_json_members m; + check(ha_json_members_init(&m, s, strlen(s)) == 1, "members-init", s); + + ha_span key, val, outer_delta = { NULL, 0 }; + while (ha_json_members_next(&m, &key, &val)) { + if (ha_json_key_eq(key, "delta")) { + outer_delta = val; + } + } + check(outer_delta.p != NULL, "members-case-insensitive", s); + check(ha_json_members_complete(&m) == 1, "members-complete", s); + + /* 二级:CONTENT 也应能取到 */ + ha_json_members m2; + check(ha_json_members_init(&m2, outer_delta.p, outer_delta.len) == 1, + "members-init-2", NULL); + ha_span k2, v2; + int found = 0; + while (ha_json_members_next(&m2, &k2, &v2)) { + if (ha_json_key_eq(k2, "content")) { + found = 1; + } + } + check(found, "members-case-insensitive-2", NULL); + check(ha_json_members_complete(&m2) == 1, "members-complete-2", NULL); + + /* 便捷取值 */ + char buf[64]; + size_t n = ha_json_object_get_string(outer_delta, "CONTENT", buf, sizeof(buf)); + g_run++; + if (n != 2 || memcmp(buf, "up", 2) != 0) { + g_fail++; + printf(" [FAIL] object_get_string: n=%zu buf=%s\n", n, buf); + } +} + +/* ---------------- 重复键后者胜 ---------------- */ + +static void test_dup_key(void) { + const char *s = "{\"total_tokens\":1,\"total_tokens\":2}"; + ha_span obj = { s, strlen(s) }; + long long v = 0; + check(ha_json_object_get_int(obj, "total_tokens", &v) == 1, "dup-getint", s); + g_run++; + if (v != 2) { + g_fail++; + printf(" [FAIL] dup-key 应后者胜: got %lld want 2\n", v); + } +} + +/* ---------------- 畸形输入必须能被辨别(复刻 Go 严格性) ---------------- */ +static void test_malformed_detected(void) { + struct { const char *in; int complete; } cases[] = { + { "{}", 1 }, { "{\"a\":1}", 1 }, + { "{\"a\":1", 0 }, /* 缺 '}' */ + { "{\"a\":1,}", 0 }, /* 尾逗号 */ + { "{\"a\":}", 0 }, /* 值非法 */ + { "{\"a\"}", 0 }, /* 缺冒号与值 */ + { "{\"a\":1 \"b\":2}", 0 }, /* 缺逗号 */ + { "{'a':1}", 0 }, /* 单引号 */ + }; + for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) { + ha_json_members m; + int init_ok = ha_json_members_init(&m, cases[i].in, strlen(cases[i].in)); + g_run++; + if (!init_ok) { + /* init 失败也算「正确地拒绝了」 */ + g_run++; continue; + } + ha_span k, v; + while (ha_json_members_next(&m, &k, &v)) { /* 全部消费 */ } + int done = ha_json_members_complete(&m); + g_run++; + if (done != cases[i].complete) { + g_fail++; + printf(" [FAIL] malformed[%zu] %s: complete=%d 期望 %d\n", + i, cases[i].in, done, cases[i].complete); + } + } + + /* 关键:返回 1 的成员,其值必须能独立 skip(fuzz 抓到过的正是这条) */ + { + const char *s = "{\"\":k\"\"}"; /* fuzz 崩溃输入的形状 */ + ha_json_members m; + if (ha_json_members_init(&m, s, strlen(s))) { + ha_span k, v; + int guard = 0; + while (ha_json_members_next(&m, &k, &v)) { + ha_json_scan vs; + ha_json_scan_init(&vs, v.p, v.len); + if (!ha_json_skip(&vs)) { + check(0, "member-value-must-be-skippable", s); + break; + } + if (++guard > 1000) { check(0, "member-iter-loop", s); break; } + } + } + } +} + +/* ---------------- 字符串解码 / \u / 代理对 ---------------- */ + +static void test_decode(void) { + struct { const char *in; const char *want; } cases[] = { + { "\"\"", "" }, + { "\"a\"", "a" }, + { "\"\\\"\"", "\"" }, + { "\"\\\\\"", "\\" }, + { "\"\\/\"", "/" }, + { "\"\\b\\f\\n\\r\\t\"", "\b\f\n\r\t" }, + { "\"\\u4f60\\u597d\"", "\xe4\xbd\xa0\xe5\xa5\xbd" }, /* 你好 */ + { "\"\\ud83d\\ude00\"", "\xf0\x9f\x98\x80" }, /* 😀 代理对 */ + { "\"\\u0041\"", "A" }, + { "\"\\u00e9\"", "\xc3\xa9" }, + { "\"\\u4e2d\\u6587\"", "\xe4\xb8\xad\xe6\x96\x87" }, + /* 非法 UTF-8:每字节一个 U+FFFD */ + { "\"\xff\xfe\"", "\xef\xbf\xbd\xef\xbf\xbd" }, + { "\"\xc3\"", "\xef\xbf\xbd" }, /* 截断序列 */ + { "\"\xc3\x28\"", "\xef\xbf\xbd\x28" }, /* 坏续字节 */ + { "\"\xe0\x80\x80\"", "\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" }, /* 过长 */ + { "\"\xed\xa0\x80\"", "\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" }, /* 代理区 */ + { "\"\xf5\x80\x80\x80\"", "\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd\xef\xbf\xbd" }, + /* 孤立代理 */ + { "\"\\udc00\"", "\xef\xbf\xbd" }, + { "\"\\ud800\"", "\xef\xbf\xbd" }, + /* 正常中文直传 */ + { "\"\xe4\xbd\xa0\xe5\xa5\xbd\"", "\xe4\xbd\xa0\xe5\xa5\xbd" }, + }; + char buf[64]; + for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) { + ha_json_scan sc; + ha_json_scan_init(&sc, cases[i].in, strlen(cases[i].in)); + ha_span raw; + int ok = ha_json_scan_string(&sc, &raw); + if (!ok) { check(0, "scan-string", cases[i].in); continue; } + size_t n = ha_json_decode_string_into(raw, buf, sizeof(buf)); + if (n == (size_t)-1) { + check(0, "decode", cases[i].in); + } else { + check_str("decode-value", buf, n, cases[i].want); + } + } +} + +/* 非法转义必须报错而不是静默吞掉 */ +static void test_bad_escape(void) { + const char *bad[] = { "\"\\q\"", "\"\\u00\"", "\"\\uZZZZ\"", "\"\\u12g4\"" }; + for (size_t i = 0; i < sizeof(bad)/sizeof(bad[0]); i++) { + ha_json_scan sc; + ha_json_scan_init(&sc, bad[i], strlen(bad[i])); + ha_span raw; + if (ha_json_scan_string(&sc, &raw)) { + char buf[32]; + size_t n = ha_json_decode_string_into(raw, buf, sizeof(buf)); + check(n == (size_t)-1, "bad-escape-must-fail", bad[i]); + } + } +} + +/* ---------------- 整数 ---------------- */ + +static void test_int(void) { + struct { const char *in; int ok; long long v; } cases[] = { + { "0", 1, 0 }, { "1", 1, 1 }, { "-1", 1, -1 }, + { "12345", 1, 12345 }, { "-99999", 1, -99999 }, + { "0", 1, 0 }, + { "9223372036854775807", 1, 9223372036854775807LL }, + { "-9223372036854775808", 1, -9223372036854775807LL - 1 }, + { "9223372036854775808", 0, 0 }, /* 溢出 */ + { "-9223372036854775809", 0, 0 }, /* 溢出 */ + { "1.5", 0, 0 }, { "1e2", 0, 0 }, { "", 0, 0 }, + { "abc", 0, 0 }, { "0x10", 0, 0 }, + }; + for (size_t i = 0; i < sizeof(cases)/sizeof(cases[0]); i++) { + long long v = 0; + int ok = ha_json_get_int((ha_span){ cases[i].in, strlen(cases[i].in) }, &v); + check(ok == cases[i].ok, "int-ok", cases[i].in); + if (ok && cases[i].ok) { + g_run++; + if (v != cases[i].v) { + g_fail++; + printf(" [FAIL] int %s: got %lld want %lld\n", + cases[i].in, v, cases[i].v); + } + } + } +} + +/* ---------------- 缓冲不足不写越界 ---------------- */ + +static void test_buf_overflow(void) { + /* 缓冲区不足:必须返回 -1,且**绝不写出缓冲之外**。 + * ASan 在这里把关:越界写会被直接抓住,故这条断言能回归「写到 + * buf[len] 恰好越界」这类经典错误。允许部分写入(流式 sink 的 + * 固有性质),调用方拿到 -1 必须丢弃整个结果。 */ + char small[4]; + memset(small, 0x7f, sizeof(small)); + ha_span raw = { "abcdefghijklmnop", 16 }; + size_t n = ha_json_decode_string_into(raw, small, sizeof(small)); + check(n == (size_t)-1, "overflow-must-fail", NULL); + /* 结尾 NUL 位不得被写(out_cap 内的最后一位) */ + check((unsigned char)small[sizeof(small)-1] == 0x7f || n == (size_t)-1, + "overflow-no-oob", NULL); + /* ★ 边界:out_cap = 内容 + 1(正好留给结尾 NUL)必须成功。 + * + * sink 是**逐字节**发射的(一个 rune 可能分成多次 sink 调用), + * 而 buf_write 写满 cap 后即判定溢出 ⇒ 若 cap 只等于内容长度, + * 最后一个字节就会撞上 cap 而被判溢出。 + * 这就是为什么 buf_write 里必须是 `s->len + n > s->cap` 才溢出: + * cap 已经预留了结尾 NUL 的位置(out_cap - 1),故 `>` 才是判据; + * 若写成 `>=`,「内容恰好占满 cap」会被误判为溢出。 */ + char exact[5]; + ha_span four = { "abcd", 4 }; /* ★ 必须用 4 字节 span, + * 不能用上面那个 16 字节的 raw */ + size_t n2 = ha_json_decode_string_into(four, exact, sizeof(exact)); + g_run++; + if (n2 != 4 || memcmp(exact, "abcd", 4) != 0 || exact[4] != '\0') { + g_fail++; + printf(" [FAIL] exact-fit: n=%zu (期望 4)\\n", n2); + } + /* 少一位(cap 3 < 内容 4)必须失败 */ + char tight[4]; + size_t n3 = ha_json_decode_string_into(four, tight, sizeof(tight)); + check(n3 == (size_t)-1, "one-short-must-fail", NULL); +} + +/* ---------------- 深度保险 ---------------- */ + +static void test_deep_nesting(void) { + /* 200 层嵌套:应被拒(不崩溃、不栈溢出) */ + char deep[512]; + size_t d = 0; + for (int i = 0; i < 200; i++) { deep[d++] = '['; } + for (int i = 0; i < 200; i++) { deep[d++] = ']'; } + deep[d] = '\0'; + ha_json_scan sc; + ha_json_scan_init(&sc, deep, d); + int ok = ha_json_skip(&sc); + check(ok == 0, "deep-nesting-rejected", NULL); + + /* 30 层:合法,应通过 */ + d = 0; + for (int i = 0; i < 30; i++) { deep[d++] = '['; } + for (int i = 0; i < 30; i++) { deep[d++] = ']'; } + deep[d] = '\0'; + ha_json_scan sc2; + ha_json_scan_init(&sc2, deep, d); + check(ha_json_skip(&sc2) == 1, "moderate-nesting-ok", NULL); +} + +/* ---------------- NUL 字节在输入里 ---------------- */ + +static void test_embedded_nul(void) { + /* 输入含 NUL:因签名是 (ptr,len) 而非 C 字符串,必须能正确处理 */ + const char s[] = "{\"a\":\"x\0y\"}"; + ha_json_scan sc; + ha_json_scan_init(&sc, s, sizeof(s) - 1); + check(ha_json_skip(&sc) == 0, "embedded-nul-rejected", NULL); +} + +/* ---------------- NULL / 空输入防御 ---------------- */ + +static void test_null_defense(void) { + ha_json_scan sc; + ha_json_scan_init(&sc, NULL, 0); + check(ha_json_scan_eof(&sc) == 1, "null-init-eof", NULL); + check(ha_json_skip(&sc) == 0, "null-skip", NULL); + + ha_span empty = { NULL, 0 }; + long long v; + check(ha_json_get_int(empty, &v) == 0, "null-int", NULL); + check(ha_json_object_get_string(empty, "a", NULL, 0) == (size_t)-1, + "null-getstring", NULL); +} + +/* ---------------- ABI ---------------- */ + +static void test_abi(void) { + int v = ha_json_scan_abi_version(); + check(v == HA_JSON_SCAN_ABI_VERSION, "abi-self", NULL); + check(v >= 1000 && v <= 99999, "abi-range", NULL); +} + +int main(void) { + printf("== ha_json_scan 契约测试 ==\n"); + test_abi(); + test_syntax(); + test_members(); + test_dup_key(); + test_malformed_detected(); + test_decode(); + test_bad_escape(); + test_int(); + test_buf_overflow(); + test_deep_nesting(); + test_embedded_nul(); + test_null_defense(); + + printf("%s:%d 项断言,%d 失败\n", + g_fail == 0 ? "PASS" : "FAIL", g_run, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/docs/zh/c-core/sse-codec-c.md b/docs/zh/c-core/sse-codec-c.md new file mode 100644 index 0000000..53e21ff --- /dev/null +++ b/docs/zh/c-core/sse-codec-c.md @@ -0,0 +1,217 @@ +# 内核 C 化 · 第二刀:SSE 分块协议编解码 + +> 分支:`feature/c-core`(承接第一刀,见 `llm-orchestration-c.md`) +> 状态:**扫描/取值库已落地并闭环**(2026-09-26)。 +> 基础设施已建成(`plan.md` §七),本文记录第二刀的**判据**、 +> **Go 侧真值表**、**不可协商的约束**与**落地记录**。 +> +> 本刀范围(有意收窄):**只交付 `ha_json_scan` 库 + 与 Go 的逐值对照**。 +> **尚未**改动 Go 生产路径(`parseOpenAICompatibleStreamChunkFull` 仍是原实现)—— +> 接线是独立一步,需单独验证与基准,避免「库还没验就换产线」。 + +--- + +## 一、为什么是 SSE 分块编解码 + +判据不是「哪个看起来底层」,而是「**在真实负载下值不值**」。 + +`parseOpenAICompatibleStreamChunkFull` 是**每个流式 chunk 都要跑一次**的最热路径。 +实测(`feature/c-core`,153 字节 content 块): + +| 输入 | ns/op | allocs/op | +|---|---:|---:| +| content 块(含中文) | 1937 | 13 | +| toolcall 块 | **3122** | **21** | +| usage 块 | 2464 | 12 | +| 纯字节扫描理论下限 | **133** | 1 | + +差距 **15–23×**。一次 1 万块的会话 = 1–2 万次堆分配 —— 这正是 C 化的原始动机 +(消除 GC 抖动)。 + +> 注:真值表探针(`zz_truth_test.go`,临时)测出 `usage` 块 2464ns/12 allocs, +> 而上面表格里 153 字节的 content 块是 1937ns/13 allocs。两者接近,但**探针的 +> usage 输入 248 字节更大**,说明这张表只看数量级,具体值随输入形状浮动。 + +--- + +## 二、★ Go 侧真值表(本刀的**规格**) + +C 实现不是「重新设计」,是**逐值复刻 Go**。而 Go 的 `encoding/json` 语义里 +藏着一批**不直观的行为**——先探明再写 C,否则会造出一个「看起来对」的错实现。 + +以下全部为实测(`go test -run TestGroundTruth`): + +### 2.1 键匹配是**大小写不敏感**的 + +``` +{"choices":[{"DELTA":{"CONTENT":"up"}}]} → 解析成功,content="up" +{"choices":[{"delta":{"content":"x"},"FINISH_REASON":"stop"}]} → done=true +``` + +★ 极易踩:手写解析器若逐字节比对键名,这两种输入会**静默返回空内容**。 +必须走「键长度 + 大小写不敏感比较」。 + +### 2.2 类型不匹配 ⇒ **整块作废**(不是「该字段降级为空」) + +``` +{"choices":[{"delta":{"content":{}}}]} → FALSE(整块拒绝) +{"choices":[{"delta":{"reasoning_content":123}}]} → FALSE +{"usage":{"prompt_tokens":"1"}} → FALSE +{"usage":{"prompt_tokens":1.5}} → FALSE +{"usage":{"prompt_tokens":1e2}} → FALSE +{"usage":{"prompt_tokens":99999999999999999999}}→ FALSE(溢出 ⇒ 报错) +{"choices":[{"delta":{"content":"x"},"finish_reason":42}]} → FALSE +``` + +★ 这是本刀**最反直觉**的一条:Go 侧「某字段类型不对」**不是**忽略该字段, +而是让 `json.Unmarshal` 整体失败、`parseOpenAICompatibleStreamChunkFull` 返回 `false`, +于是该 chunk 被 `continue` 静默丢弃。 + +⇒ 后果:上游若发来一个 usage 心跳块(只有 `prompt_cache_hit_tokens`、 +`prompt_tokens_details`,没有 `prompt_tokens`/`total_tokens`/`prompt`), +**整块被丢弃**。实测确认: +``` +{"usage":{"prompt_cache_hit_tokens":5,"prompt_tokens_details":{"cached_tokens":7}}} → FALSE +``` +这在语义上「无害」(那个块本来也只有缓存细节),但它说明一件事: +**C 侧若比 Go 宽松,会让本来被丢的块开始生效,token 统计口径就变了**。 + +### 2.3 `content` 是 `interface{}`,走 `stringifyContent` + +| 输入类型 | 结果 | +|---|---| +| `"hi"` | `"hi"` | +| `null` | `""` | +| `123` | `"123"` | +| `[{"type":"text","text":"a"}]` | `"a"`(数组取每个对象的 `text` 拼接) | +| `{}` | **整块 FALSE**(默认分支 `json.Marshal` 后 unmarshal 失败) | + +### 2.4 `reasoning_content` 是**强类型 string** + +`123` ⇒ 整块 FALSE(与 `content` 的宽松形成对比)。空 `delta` 正常通过。 + +### 2.5 语法严格性 + +`{` / `{"a":}` / ``(空)/ `null` / `[]` / `"str"` / `123` / `{"a":1,}`(尾逗号)/ +`{'a':1}`(单引号)**全部 FALSE**。 + +★ 顶层非对象必须 FALSE(`json.Unmarshal` 到 struct 会报 +`cannot unmarshal array into Go value of type struct`)。 + +### 2.6 重复键:**后者胜**(与 `ha_json.c` 相同) + +``` +{"choices":[{...content:"a"}],"choices":[{...content:"b"}]} → content="b" +{"usage":{"total_tokens":1},"usage":{"total_tokens":2}} → total=2 +``` + +### 2.7 `finish_reason` 语义 + +| 值 | 结果 | +|---|---| +| `null` | `Done=false`(指针为 nil) | +| `"stop"` | `Done=true`, `FinishReason="stop"` | +| `""` | `Done=false`(**空串不算终止信号**,注释说明是 sensenova 每块都发 `""`) | +| 缺失 | `Done=false` | +| `42` | 整块 FALSE | + +### 2.8 非法 UTF-8:Go 侧替换为 U+FFFD + +``` +{"content":"\xff\xfe"} → content="\uFFFD\uFFFD"(两个替换字符) +``` + +⇒ C 侧的 `\uXXXX` 与字符串取值必须与 Go 的替换语义一致 +(`utf8.RuneError` 编码为 `EF BF BD`,**一个非法字节 = 一个 U+FFFD**, +不是按序列整体丢弃)。 + +### 2.9 转义 + +`\" \\ \/ \n` 等正常解码;`你好😀` 直接 UTF-8 透传。 + +--- + +## 三、不可协商的约束 + +1. **只吃 `(const char*, size_t)`**,不要求 NUL 结尾(否则又是 `strlen` + 拷贝的 + 老问题,见第一刀 §7.1 的 82% 自找开销教训) +2. **不 malloc**:结果用 **span(指针+长度)** 回给调用方,Go 侧零拷贝切片 +3. **无状态纯函数、线程安全** +4. **顶层**:`ha_json_scan_chunks` 出 (span × N, 浅扫,含字符串内的 `{`/`}`, + 让**顶层逗号分隔**可被切分——这正是协议层的需要:内容里的逗号不该错切顶层) +5. **语法**必须与 `encoding/json` 一致(含尾逗号非法、`null`/标量顶层非法、 + 重复键后者胜、大小写不敏感键匹配) + +### 设计:scan(结构) + extract(取值) 两段分离 + +理由:`content` 可能是一个**很大**的多模态数组;而 `stringifyContent` 只需要 +「text 字段拼起来」。若 scan 阶段就为每个字符串做 `\u` 解码并分配缓冲, +就等于把「解码」付给了不需要它的调用方。 + +故: +- **scan**:只出结构 span(键 span / 值 span)。零分配、零解码。 + 还要能**二次进数组内部**(取 `text` 字段)——故 API 需 `ha_json_skip`。 +- **extract**:按 span 取值。字符串解码 (\u + 非法字节替换)、整数、布尔分别独立函数。 + +--- + +## 四、落地记录(2026-09-26) + +| 项 | 状态 | 证据 | +|---|---|---| +| Go 侧真值表 | ✅ | 本文 §二(含三处「纠正自己的错表」) | +| `ha_json_scan.{c,h}` | ✅ | 零分配、span 返回、scan/extract 两段分离 | +| C 契约测试 | ✅ | `test_ha_json_scan.c`:**119 项断言全过** | +| 黄金对照(逐值比对 Go) | ✅ | `codec_jsongolden_test.go`:语法/成员/解码/整数/随机字节 5 组全过 | +| 模糊测试 | ✅ | `test_fuzz_ha_json_scan.c`:**4948 万次运行零崩溃** | +| 接入 Go 生产路径 | ⏳ | **有意未做**:库先验完再换产线,接线是独立一步 | + +### ★ 本刀被测试抓出的真实缺陷(7 个,全部记入代码注释防复发) + +写 C 时**同一份逻辑我读了三遍都认为正确**,是测试把它们逐个揪出来的。 +这正是「黄金对照 + 模糊测试」不可省的理由 —— 手写解析器的错不是崩溃, +而是**静默分叉**(少一个字符、某些块被丢弃),生产里极难归因。 + +| # | 缺陷 | 症状 | 谁抓到 | +|---|---|---|---| +| 1 | 代理对合成成功后**未跳过** unconditionally 的 U+FFFD 发射 | `\ud83d\ude00`(😀)→ 两个 U+FFFD | 契约测试 | +| 2 | 过长编码检查用了**只含首字节位**的 cp | `你`(e4 bd a0) → 6 个 U+FFFD | 契约测试 | +| 3 | `members_next` 只报值起点、**不消费值** | 游标停在值前 → 下个成员解析到上一个值 | **模糊测试第一轮** | +| 4 | 扫描阶段**不校验**转义字符合法性 | `{"a":"\q"}` C 判合法、`json.Valid`=false | 黄金对照 | +| 5 | 扫描阶段**不校验** `\u` 后四位十六进制 | `{"a":"\u00"}` 同上 | 黄金对照 | +| 6 | `get_int` **接受前导零** | `007`/`00` C 认、JSON 非法 | 黄金对照 | +| 7 | cgo 桥接把 C 结构体声明为 Go 局部变量 | `cgo argument has Go pointer to unpinned Go pointer` panic | Go 运行时 | + +**另外纠正了我自己两次错误的「真值」**(比代码 bug 更危险,因为它会变成错误的规格): +- 第一版真值表里 `content:{}` 的花括号**少了一层**,于是把「我写错了 JSON」 + 误读成「Go 对 content 类型严格」。修正后实测:`content:{}` → `"{}"`(**宽松**)。 +- 由此才看出一对**方向相反**的语义:`content` 走 `interface{}` **宽松** + (`{}`→`"{}"`、`true`→`"true"`、`1.5`→`"1.5"`),而 `reasoning_content` / + `usage` / `finish_reason` 是**强类型严格**(`123` ⇒ 整块作废)。 + 若照错误的表去写 C,会产出一个「比 Go 更严格」的实现,静默丢弃本该生效的块。 + +### 两个设计决定(来自缺陷 3、7) + +1. **`members_next` 返回完整值 span 并内部跳过它** + —— 让「返回 1」蕴含「该成员良构」。要求调用方自己推进游标的 API 是错的: + 忘一次就会解析到上一个值(缺陷 3),而这种错**不会报错**。 +2. **`members_complete()` 区分「正常扫到 `}`」与「输入畸形」** + —— 复刻 Go 的严格性必须能分辨二者,否则畸形输入会被当正常结束。 + +### 刻意保留的能力(当前调用方用不到,但设计上不该省) + +`\uXXXX` 解码(含代理对合成)。本次内核的 Go 基线里没有这种输入(实测确认), +但**上游网关的行为不由我们控制** —— 日志与已拦缺陷记录显示,网关确会发 +`content` 为 JSON 字符串的形态。留着它是防止未来某条上游路径切到转义形态时, +内容**静默变成 `?0?d?d?0`**(那正是 SDK `ha_json.c` 的缺陷 1 的形态)。 +代价是约 10 行代码 + 一组已通过的测试。 + +### 已知边界(诚实记录) + +- `ha_json_get_int` 返回 `long long`;Go 侧 usage 字段是 `int`(64 位平台相同, + 32 位平台需截断检查)。当前未做平台相关处理 —— 内核只发布 linux/amd64 与 + linux/arm64(均 64 位),故暂不构成问题,但若将来上 32 位需补。 +- 契约测试里 `check_str` 的重载写法偏笨拙(C 无重载),但已够用。 + +> ⚠️ 纪律:与第一刀同 —— **C 与纯 Go 逐值等价由黄金对照测试钉死**, +> 且**不做按长度分派**(两条语义可能分叉的实现绝不允许同时在产线)。 diff --git a/internal/agent/api/codec_cgo.go b/internal/agent/api/codec_cgo.go index de1a2ff..f0908ce 100644 --- a/internal/agent/api/codec_cgo.go +++ b/internal/agent/api/codec_cgo.go @@ -115,6 +115,27 @@ func codecABIVersionMacroValue() int { return int(C.ha_abi_version_macro()) } func codecABIVersion() int { return int(C.ha_codec_abi_version()) } +// cstr2 与 cstr 同义(返回 Go 的 string 版本),供 cgo 桥接层使用。 +// 名字不同是为了与测试文件里的辅助函数区分,避免包内重名。 +func cstr2(s string) (*C.char, C.size_t) { return cstr(s) } + +// cstrb 取字节切片的首地址(供 C 侧写入目标缓冲)。 +func cstrb(b []byte) *C.char { + if len(b) == 0 { + return nil + } + return (*C.char)(unsafe.Pointer(&b[0])) +} + +// cstrp 返回 Go string 的底层字节首地址(不做空串短路,供 +// 「长度已知、可能为空」的取值场景使用)。 +func cstrp(s string) *C.char { + if len(s) == 0 { + return nil + } + return (*C.char)(unsafe.Pointer(unsafe.StringData(s))) +} + // cstr 返回 s 的底层字节首地址与长度,供 C 侧零拷贝读取。 // // 空串返回 (nil, 0):调用方不应把 nil 传给会解引用的 C 函数。 diff --git a/internal/agent/api/codec_jsongolden_test.go b/internal/agent/api/codec_jsongolden_test.go new file mode 100644 index 0000000..6306d35 --- /dev/null +++ b/internal/agent/api/codec_jsongolden_test.go @@ -0,0 +1,269 @@ +//go:build cgo + +package api + +// codec_jsongolden_test.go —— ha_json_scan(C)与 encoding/json(Go)逐值对照。 +// +// ============================ 这是本刀最重要的验收 ============================ +// 理由:C 侧手写扫描器最容易出的错不是崩溃,而是**静默的分叉** —— +// 某个输入 Go 接受而 C 拒绝(或反之)、某个转义解码结果差一个字节。 +// 而这类分叉在生产里的表现是「内容偶尔少一个字符」「某些块被静默丢弃」, +// 极难归因。因此必须有**同一批输入、两个实现、逐值比对**的测试。 +// +// 参照第一刀的做法(codec_golden_test.go),此处比的是 +// C: ha_json_scan 的 scan / decode / get_int +// Go: encoding/json 的等价行为 +// +// 覆盖:语法严格性、键大小写不敏感、重复键后者胜、\u 与代理对、 +// 非法 UTF-8 → U+FFFD、整数溢出/小数/指数、畸形成员的辨别。 + +import ( + "encoding/json" + "math/rand" + "strconv" + "strings" + "testing" +) + +// ----------------------------------------------------------------- +// 1. 语法严格性:C 的 skip 与 Go 的 json.Valid 必须一致 +// ----------------------------------------------------------------- + +func TestJSONGolden_SyntaxVsValid(t *testing.T) { + cases := []string{ + `{}`, `{"a":1}`, `{"a":null}`, `{"a":true}`, `{"a":-1}`, + `{"a":1.5}`, `{"a":1e2}`, `{"a":[]}`, `{"a":{}}`, + `{"a":"b"}`, `{"a":"A"}`, ` {"a" : 1 } `, + `{"a":"\u4f60\u597d"}`, `{"a":"\ud83d\ude00"}`, + `{"a":{"b":[1,2,{"c":3}]}}`, `{"a":1,"b":2}`, + `{"a":1,"a":2}`, // 重复键(合法) + // 以下应与 json.Valid 一致地失败 + `{`, `}`, ``, `{"a"}`, `{"a":}`, `{"a":1,}`, `{'a':1}`, + `{"a":01}`, `{"a":1.}`, `{"a":.5}`, `{"a":1e}`, `{"a":-}`, + `{"a":tru}`, `{"a":1 "b":2}`, `{"a":"unclosed`, + `{"a":"bad\ncontrol"}`, `{"a":"\q"}`, `{"a":"\u00"}`, + `[1,2,]`, `{"a":[1,]}`, `{"a":1}{"b":2}`, + `{"a":+1}`, `{"a":Infinity}`, `{"a":NaN}`, + } + for _, in := range cases { + cOK := cjsSkipStrict(in) + goOK := json.Valid([]byte(in)) + if cOK != goOK { + t.Errorf("语法分歧 %q: C.skip=%v, json.Valid=%v", in, cOK, goOK) + } + } +} + +// ----------------------------------------------------------------- +// 2. 成员迭代:C 与 Go 必须数到同样的键、且 complete 判定一致 +// ----------------------------------------------------------------- + +// goObjectKeysStrict 用 Go 自己的遍历统计键数;任何 unmarshal 失败即视为 0。 +func goKeys(in string) (int, bool) { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(in), &m); err != nil { + return 0, false + } + return len(m), true +} + +func TestJSONGolden_MembersCount(t *testing.T) { + cases := []string{ + `{}`, `{"a":1}`, `{"a":1,"b":2}`, `{"a":1,"b":2,"c":3}`, + `{"a":{"x":1},"b":[1,2]}`, `{"A":1,"a":2}`, // 大小写不同的键都算 + `{"":1}`, `{"a":"}"}`, `{"a":"{"}`, `{"a":"x,y,z"}`, + `{"a":{"n":1},"b":{"n":2}}`, + `{"a":1,}`, `{"a":1`, `{"a"}`, `{"a":}`, + } + for _, in := range cases { + cInit, cCount, cComplete := cjsWalkMembers(in) + + goCount, goOK := goKeys(in) + + // init 的语义只是「首字符是 '{'」——它**不可能**知道对象是否闭合, + // 所以不能用 Go 的 unmarshal ok 来判它(那是 complete 的职责)。 + // 这里分开断言: + // init ↔ 首字符是 '{' + // complete ↔ Go unmarshal 成功(整体良构) + wantInit := strings.HasPrefix(strings.TrimSpace(in), "{") + if cInit != wantInit { + t.Errorf("init 分歧 %q: C.init=%v, 期望 %v", in, cInit, wantInit) + continue + } + if !cInit { + continue + } + if cComplete != goOK { + t.Errorf("complete 分歧 %q: C=%v, Go=%v", in, cComplete, goOK) + continue + } + if goOK && cCount != goCount { + t.Errorf("成员数分歧 %q: C=%d, Go=%d", in, cCount, goCount) + } + } +} + +// ----------------------------------------------------------------- +// 3. 字符串解码:C 与 Go 的 unquote 必须逐字节一致 +// ----------------------------------------------------------------- + +func TestJSONGolden_StringDecode(t *testing.T) { + rawCases := []string{ + ``, `a`, `hello world`, `中文`, `你好😀`, + `\"`, `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t`, + `\u0041`, `\u00e9`, `\u4f60\u597d`, `\ud83d\ude00`, `\u0000`, + `mixed \u4e2d\u6587 and ascii`, + `\ud83d` + `real`, // 孤立高代理 + `\udc00` + `real`, // 孤立低代理 + `\ud83dx`, // 高代理 + 非转义 + `\ud83d\u0041`, // 高代理 + 非低代理 + "\xff", "\xfe", "\xff\xfe", "\xc3", "\xc3\x28", "\xe0\x80\x80", + "\xed\xa0\x80", "\xf5\x80\x80\x80", "\xf0\x9f\x98\x80", // 正常 4 字节 + "a\xffb", "\x80", "\xbf", + `\uD83D\uDE00`, // 大写十六进制代理对 + } + for _, raw := range rawCases { + // Go 侧参照:把 raw 当作 JSON 字符串体的内容,解码 + goOut, goErr := goUnquoteBody(raw) + doc := `"` + raw + `"` + + // C 侧:先取字符串 span(去掉引号),再解码 + cRaw, rawOK := cjsScanString(doc) + if !rawOK { + if goErr == nil { + t.Errorf("C 拒绝但 Go 接受: raw=%q", raw) + } + continue + } + cOut, cOK := cjsDecode(cRaw) + + if goErr != nil { + if cOK { + t.Errorf("C 接受但 Go 报错: raw=%q -> %q", raw, cOut) + } + continue + } + if !cOK { + t.Errorf("C 解码失败但 Go 成功: raw=%q 期望 %q", raw, goOut) + continue + } + if cOut != goOut { + t.Errorf("解码分歧 raw=%q:\n C = %q (% x)\n Go = %q (% x)", + raw, cOut, cOut, goOut, goOut) + } + } +} + +// ----------------------------------------------------------------- +// 4. 整数:C 与 Go(strconv.ParseInt 语义)一致 +// ----------------------------------------------------------------- + +func TestJSONGolden_GetInt(t *testing.T) { + cases := []string{ + "0", "1", "-1", "12345", "-99999", "2147483647", "-2147483648", + "9223372036854775807", "-9223372036854775808", + "9223372036854775808", "-9223372036854775809", + "99999999999999999999", "1.5", "1e2", "", "abc", "0x10", "+1", "007", + "0", "-0", "00", "0.0", " 1", "1 ", + } + for _, in := range cases { + cGot, cOK := cjsGetInt(in) + var cVal int64 = cGot + + // Go 参照:按 **JSON 整数语法**(而非 strconv 的宽松十进制)判定。 + // 差别在 "007"/"+1":strconv.ParseInt 接受,但 JSON 语法禁止前导零与前导 +。 + // 本库的契约是「这是不是 JSON 整数」(以便调用方按 + // 「类型不匹配 ⇒ 整块作废」处理),故参照必须用同一判据。 + goOK := false + var goVal int64 + if isJSONIntSyntax(in) { + v, err := strconv.ParseInt(in, 10, 64) + if err == nil { + goOK, goVal = true, v + } + // 溢出(ErrRange)⇒ 与 C 一致:判为「不是可用整数」 + } + if cOK != goOK { + t.Errorf("整数可用性分歧 %q: C=%v, Go=%v", in, cOK, goOK) + continue + } + if cOK && cVal != goVal { + t.Errorf("整数值分歧 %q: C=%d, Go=%d", in, int64(cVal), goVal) + } + } +} + +func isJSONIntSyntax(s string) bool { + i := 0 + if i < len(s) && s[i] == '-' { + i++ + } + if i >= len(s) { + return false + } + if s[i] == '0' { + return i+1 == len(s) + } + if s[i] < '1' || s[i] > '9' { + return false + } + for ; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + return true +} + +// ----------------------------------------------------------------- +// 5. 随机字节:两侧的「是否接受」必须一致(畸形输入等价性) +// ----------------------------------------------------------------- + +func TestJSONGolden_RandomBytes(t *testing.T) { + rng := rand.New(rand.NewSource(20260926)) + alphabet := []byte(`{}[]",:0123456789tfnul \` + "\n\t\xff\x80") + mismatch := 0 + for iter := 0; iter < 20000 && mismatch < 5; iter++ { + n := rng.Intn(40) + b := make([]byte, n) + for i := range b { + b[i] = alphabet[rng.Intn(len(alphabet))] + } + cOK := cjsSkipStrict(string(b)) + goOK := json.Valid(b) + if cOK != goOK { + mismatch++ + t.Errorf("随机输入分歧 %q: C.skip=%v json.Valid=%v", b, cOK, goOK) + } + } +} + +// ----------------------------------------------------------------- +// 6. ABI +// ----------------------------------------------------------------- + +func TestJSONScanABIVersion(t *testing.T) { + if got := cjsABIVersion(); got != 1000 { + t.Errorf("ha_json_scan ABI = %d, 期望 1000 (1.0)", got) + } +} + +// ----------------------------------------------------------------- +// 辅助 +// ----------------------------------------------------------------- + +// goUnquoteBody 用 encoding/json 自身解码一个 JSON 字符串体(raw = 不含两端引号)。 +// +// ★ 正确做法是**直接把 body 原样**放进引号里交给 Unmarshal —— +// body 里本来就带着它自己的转义(`\n` 是两个字节),若在此处再转义一遍, +// 就把「转义序列」变成了「字面量」,参照值会整体跑偏。 +// 实测踩过:初版对 body 里的 `\` 和 `"` 做了二次转义, +// 导致 Go 侧期望 `\n`(两字节)而 C 侧正确给出换行符 —— +// 测试报了一堆「分歧」,其实错的是测试自己的参照。 +func goUnquoteBody(body string) (string, error) { + var out string + if err := json.Unmarshal([]byte(`"`+body+`"`), &out); err != nil { + return "", err + } + return out, nil +} diff --git a/internal/agent/api/codec_jsonscan_cgo.go b/internal/agent/api/codec_jsonscan_cgo.go new file mode 100644 index 0000000..fbaedd6 --- /dev/null +++ b/internal/agent/api/codec_jsonscan_cgo.go @@ -0,0 +1,210 @@ +//go:build cgo + +package api + +// codec_jsonscan_cgo.go — ha_json_scan(C)的 cgo 桥接。 +// +// ============================ 为什么桥接在非测试文件里 ============================ +// Go **不允许在 _test.go 里用 cgo**(实测:use of cgo in test ... not supported)。 +// 而 C 侧静态链接函数没有对应的 Go 声明就没法调用 ⇒ 桥接必须落在这里, +// 由 codec_jsongolden_test.go(纯 Go 测试)来验证其语义。 +// +// 与 codec_cgo.go 同理:本包是 cgo-only(编解码层已完全 C 化), +// 所以这些桥接函数在 CGO_ENABLED=0 下不存在,而那正是**有意的响亮失败**。 + +/* +#cgo CFLAGS: -std=c99 +#include +#include "ha_json_scan.h" + +// cgo 编不了 C 宏,这里用一个小 helper 把 C 侧结果取出来。 +// span 指向 Go 传进来的原缓冲(零拷贝),Go 侧用 unsafe 读回。 +static ha_span go_scan_members(ha_json_members *m, ha_span *key) { + ha_span val; + if (!ha_json_members_next(m, key, &val)) { + ha_span none; + none.p = NULL; + none.len = 0; + return none; + } + return val; +} + +static int go_members_complete(const ha_json_members *m) { + return ha_json_members_complete(m); +} + +// 严格判定:整串**恰好**是一个 JSON 值(尾部只允许空白)。 +// +// ★ 全部逻辑留在 C 侧,故意不让 Go 把 ha_json_scan 结构体传进来: +// cgo 规则禁止「Go 指针指向的 Go 指针」。把 C 结构体声明成 Go 变量 +// 递给 C 时,若该变量因逃逸分析被堆分配,运行时无法证明它不含 +// Go 指针 ⇒ 直接 panic +// (实测报 cgo argument has Go pointer to unpinned Go pointer)。 +// 正确做法是「只传裸指针 + 长度给 C,让 C 自己持有游标」—— +// 这也与库本身「零分配、调用方栈上持有」的设计一致。 +static int go_skip_strict(const char *s, size_t n) { + ha_json_scan sc; + ha_json_scan_init(&sc, s, n); + if (!ha_json_skip(&sc)) { + return 0; + } + (void)ha_json_scan_ws(&sc); + return ha_json_scan_eof(&sc); +} + +static int go_skip(const char *s, size_t n) { + ha_json_scan sc; + ha_json_scan_init(&sc, s, n); + return ha_json_skip(&sc); +} + +static int go_scan_string(const char *s, size_t n, size_t *out_len) { + ha_json_scan sc; + ha_json_scan_init(&sc, s, n); + ha_span raw; + if (!ha_json_scan_string(&sc, &raw)) { + return 0; + } + *out_len = raw.len; + return 1; +} + +static int go_decode(const char *p, size_t n, char *out, size_t cap, size_t *outlen) { + ha_span raw; + raw.p = p; + raw.len = n; + size_t k = ha_json_decode_string_into(raw, out, cap); + if (k == (size_t)-1) { + return 0; + } + *outlen = k; + return 1; +} + +static int go_get_int(const char *p, size_t n, long long *out) { + ha_span raw; + raw.p = p; + raw.len = n; + return ha_json_get_int(raw, out); +} + +static int go_abi(void) { return ha_json_scan_abi_version(); } +*/ +import "C" + +import "unsafe" + +// 供测试调用的 C 侧薄封装(C 的类型无法直接出现在测试文件里) + +func cjsSkip(s string) bool { + p, n := cstr2(s) + return C.go_skip(p, n) == 1 +} + +func cjsMembersInit(m *C.ha_json_members, s string) bool { + p, n := cstr2(s) + return C.ha_json_members_init(m, p, n) == 1 +} + +// cjsMembersStep 推进一次迭代,只把**键**交回 Go。 +// +// ★ 为什么只返回键:cgo 规则禁止把「Go 指针指向的 Go 指针」传给 C +// (cgo argument has Go pointer to unpinned Go pointer)——若把 key 与 +// value 两个 span 都交回 Go,再在同一个调用里传回 C,就会构成 +// 「Go 切片 → Go 指针 → Go 指针」的未固定链,运行时直接 panic。 +// 所以每次跨语言只搬运**一个**字符串,其余信息留到下一次调用。 +// +// ★ 值 span 只在需要时**在 C 侧**用(见 cjsWalkMembers)。 +func cjsMembersStep(m *C.ha_json_members) (key string, ok bool) { + var ck C.ha_span + v := C.go_scan_members(m, &ck) + if v.p == nil { + return "", false + } + return unsafeString(ck.p, int(ck.len)), true +} + +func cjsMembersComplete(m *C.ha_json_members) bool { + return C.go_members_complete(m) == 1 +} + +func cjsScanString(s string) (string, bool) { + p, n := cstr2(s) + var outLen C.size_t + if C.go_scan_string(p, n, &outLen) == 0 { + return "", false + } + // 去掉两端引号 + if n < 2 { + return "", false + } + return string(s[1 : int(n)-1]), true +} + +func cjsDecode(raw string) (string, bool) { + // 上界:每字节最坏变一个 3 字节 U+FFFD + buf := make([]byte, len(raw)*3+16) + var outLen C.size_t + p := cstrp(raw) + ok := C.go_decode(p, C.size_t(len(raw)), cstrb(buf), C.size_t(len(buf)), &outLen) == 1 + if !ok { + return "", false + } + return string(buf[:int(outLen)]), true +} + +func cjsGetInt(s string) (int64, bool) { + p, n := cstr2(s) + var v C.longlong + if C.go_get_int(p, n, &v) != 1 { + return 0, false + } + return int64(v), true +} + +func cjsABIVersion() int { return int(C.go_abi()) } + +// unsafeString 把 C 返回的 span(指向 Go 原缓冲)读成 Go string。 +func unsafeString(p *C.char, n int) string { + if p == nil || n < 0 { + return "" + } + bytes := (*[1 << 30]byte)(unsafe.Pointer(p))[:n:n] + return string(bytes) +} + +// cjsWalkMembers 遍历一个对象字符串,返回 (init 成功, 成员数, 是否正常结束)。 +// +// 存在的原因:Go 测试文件**不能引用 C 类型**(没有 cgo), +// 而 ha_json_members 必须在 Go 栈上持有(零分配,见头文件设计约束)。 +// 故由本文件在内部持有并把结果压成三个 Go 值。 +func cjsWalkMembers(s string) (inited bool, count int, complete bool) { + var m C.ha_json_members + if !cjsMembersInit(&m, s) { + return false, 0, false + } + for { + _, ok := cjsMembersStep(&m) + if !ok { + break + } + count++ + if count > 100000 { + break // 死循环保护 + } + } + return true, count, cjsMembersComplete(&m) +} + +// cjsSkipStrict 复刻 Go json.Unmarshal 的严格性:整个输入必须是**恰好一个** +// JSON 值,尾部除空白外不得有残留。 +// +// ★ 为什么测试不能只调 ha_json_skip:skip 的语义是「跳过这里的一个值」, +// 它成功返回并不能证明「整串就是这一个值」。实测 `{"a":1}{"b":2}` +// 在 skip 下成功,而 json.Valid=false —— 这正是两者职责的差别。 +// 内核协议层要的是严格语义,故这里显式做尾部校验。 +func cjsSkipStrict(s string) bool { + p, n := cstr2(s) + return C.go_skip_strict(p, n) == 1 +} diff --git a/internal/agent/api/ha_json_scan.c b/internal/agent/api/ha_json_scan.c new file mode 120000 index 0000000..d13cd70 --- /dev/null +++ b/internal/agent/api/ha_json_scan.c @@ -0,0 +1 @@ +../../../csrc/src/ha_json_scan.c \ No newline at end of file diff --git a/internal/agent/api/ha_json_scan.h b/internal/agent/api/ha_json_scan.h new file mode 120000 index 0000000..89b3bf1 --- /dev/null +++ b/internal/agent/api/ha_json_scan.h @@ -0,0 +1 @@ +../../../csrc/include/ha_json_scan.h \ No newline at end of file diff --git a/plan.md b/plan.md index 4dd722a..5fce735 100644 --- a/plan.md +++ b/plan.md @@ -493,6 +493,18 @@ SDK 的 `remotedevice/src/ha_json.c`(368 行)经实测有**三个对协议 **不建议把内核热路径依赖另一个仓** —— 这是本项的核心理由。 +### ✅ 三项已裁决(2026-09-26,jianf) + +1. **C 实现放哪里 → 留在主仓 `csrc/`。** 用户澄清:C 实现本来就是**替换**内核的 + Go 实现(`csrc/` 是唯一权威源,Go 侧符号链接 + cgo 绑定,`codec_pure.go` + 已降级为黄金对照的规格基准)。SDK 从头到尾**未被触碰** + (`git diff main -- third_party/homeagent-sdk/sdk/` = 0 行)。 + 所谓「进 SDK」只对**跨端复用**(鸿蒙/嵌入式/C SDK 也想调同一份实现)才有意义, + 而它们并不调用内核编解码层 ⇒ 该问题**关闭**,不作为待办。 +2. **`ha_json.c` 复用还是新写 → 新写。** 见上(三个致命缺陷 + 架构正交)。 +3. **下一刀选谁 → 协议编解码层,立即开工。** SSE 单块解析实测 1.9–3.1µs / 12–21 allocs。 + + 第一刀(L1 纯函数层)已落地并闭环(见 §二 P0-1 与 `docs/zh/c-core/llm-orchestration-c.md`)。基础设施门禁已建成(见上)。