mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
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 不复用;
下一刀即协议编解码层。
216 lines
7.4 KiB
C
216 lines
7.4 KiB
C
/*
|
||
* 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;
|
||
}
|