Files
HomeAgent/csrc/test/test_fuzz_ha_json_scan.c
JianFeeeee 4d3962a845 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 不复用;
下一刀即协议编解码层。
2026-09-26 10:07:27 +08:00

216 lines
7.4 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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;
}