mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 04:43:11 +00:00
feat(csrc): 第二刀 —— 零分配 JSON 扫描/取值层 ha_json_scan(含黄金对照)
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 不复用;
下一刀即协议编解码层。
This commit is contained in:
49
Makefile
49
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)
|
||||
|
||||
@ -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
|
||||
|
||||
232
csrc/include/ha_json_scan.h
Normal file
232
csrc/include/ha_json_scan.h
Normal file
@ -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 <stddef.h>
|
||||
|
||||
#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 */
|
||||
825
csrc/src/ha_json_scan.c
Normal file
825
csrc/src/ha_json_scan.c
Normal file
@ -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 <string.h>
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 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);
|
||||
}
|
||||
215
csrc/test/test_fuzz_ha_json_scan.c
Normal file
215
csrc/test/test_fuzz_ha_json_scan.c
Normal file
@ -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 <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <limits.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
414
csrc/test/test_ha_json_scan.c
Normal file
414
csrc/test/test_ha_json_scan.c
Normal file
@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
217
docs/zh/c-core/sse-codec-c.md
Normal file
217
docs/zh/c-core/sse-codec-c.md
Normal file
@ -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 逐值等价由黄金对照测试钉死**,
|
||||
> 且**不做按长度分派**(两条语义可能分叉的实现绝不允许同时在产线)。
|
||||
@ -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 函数。
|
||||
|
||||
269
internal/agent/api/codec_jsongolden_test.go
Normal file
269
internal/agent/api/codec_jsongolden_test.go
Normal file
@ -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
|
||||
}
|
||||
210
internal/agent/api/codec_jsonscan_cgo.go
Normal file
210
internal/agent/api/codec_jsonscan_cgo.go
Normal file
@ -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 <stdlib.h>
|
||||
#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
|
||||
}
|
||||
1
internal/agent/api/ha_json_scan.c
Symbolic link
1
internal/agent/api/ha_json_scan.c
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/src/ha_json_scan.c
|
||||
1
internal/agent/api/ha_json_scan.h
Symbolic link
1
internal/agent/api/ha_json_scan.h
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/include/ha_json_scan.h
|
||||
12
plan.md
12
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`)。基础设施门禁已建成(见上)。
|
||||
|
||||
Reference in New Issue
Block a user