Files
HomeAgent/csrc/test/test_fuzz_ha_codec.c
JianFeeeee 8070844fe9 build(csrc): C 基础设施门禁(ABI 版本 / 告警 / sanitizer / 交叉编译 / 模糊测试)
C 化从「一把刀」推进到「可持续推进」,本轮先把基础设施建起来:
不建它,后续每个 C 切片都在裸奔(无告警门禁、无内存安全检查、
无交叉编译验证、无 ABI 漂移检测)。

新增门禁(make check-csrc / check-csrc-full,已接进 make test):
- csrc-lint   gcc+clang 双编译器 × -Wall -Wextra -Wpedantic -Wshadow
              -Wconversion,零告警才算过(-Wconversion 是为 cgo 窄化
              准备的:size_t→int 截断在默认档下是静默的)
- csrc-abi    ABI 版本运行期自述 + 荒谬值检查
- csrc-headers 头文件自包含性(每个 .h 能单独编过)
- csrc-sanitize ASan+UBSan 跑 C 契约测试
- csrc-cross  arm64 交叉编译(homed 的真实发布目标)
- csrc-fuzz   libFuzzer:内存安全 + 6 条不变式(可 CI 门禁)

新增 ABI 契约(csrc/include/ha_abi.h):
- ha_codec.h 声明「签名冻结」,但冻结只写在注释里;现改为
  HA_CODEC_ABI_MAJOR/MINOR + 运行期自述 + Go 侧常量,
  三方交叉断言,版本漂移在测试期判红而非线上表现为行为诡异。
- 门禁当场抓出我自己的两个真 bug:①_Static_assert 是 C11 而项目
  是 -std=c99(-Wpedantic 报的);②##msg 不能拼接字符串字面量,
  导致两个断言共用一个 typedef 名(clang 报的)。

基础设施当场抓出的三个真实缺陷(都是「本机 gcc 能编过、别处会炸」类):
- bench 用了 POSIX clock_gettime,而 CMake 刻意 C_EXTENSIONS OFF
  (严格 c99)→ 头文件未声明;补 _POSIX_C_SOURCE(须在任何头之前)
- 头文件缺 include 时只在「恰好被别的头先包含」处静默编过
- Go 不允许在 _test.go 用 cgo ⇒ C 侧 const 桥接只能放非测试文件,
  且 cgo 生成的 *_Cvar_* 不是 Go 常量(「两边一起错成一样」的盲区,
  改用 C 函数返回 + 编译期 _Static_assert 补上)

实测(全部当场可复现):
- 双编译器 × c99/c11 零告警;ASan+UBSan PASS
- libFuzzer 91s 跑 3329316 次、零崩溃(6 条不变式全过)
- 变异测试:改 C 侧宏 / 让 Go 常量与 C 函数「一起错成一样」,
  均被对应断言抓住(证明门禁不是摆设)
- C 侧纯函数基准(首次把函数体成本与 cgo 边界成本分开测):
  ascii_1k 121.7ns/8.4GB/s;zh_1k 1434ns;truncate 两者均约 128-134ns
- 全量 go test -count=1 ./...:57 包 0 FAIL
- make build-linux-arm64 → ELF aarch64
- 纪律检查 FAIL=0;SDK 公开接口 diff = 0 行

未动:csrc/ 是内核 C ABI,不属 third_party/homeagent-sdk 公开接口。
2026-09-26 09:44:17 +08:00

123 lines
5.0 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_codec.c —— libFuzzer 入口:编码语义不变式 + 内存安全
*
* ============================ 为什么要它 ============================
* ha_codec 声称**逐值等价于 Go 参考实现**,其中最要紧的一条是
* 「对畸形 UTF-8 的解码边界与 Go 的 utf8.DecodeRuneInString 一致」。
* 而这条行为在正常输入下**永远测不到** —— 只有随机字节才能覆盖
* 截断的多字节序列 / 过长编码 / 代理对 / 超 U+10FFFF / 内嵌 NUL。
*
* Go 侧用 TestGolden_InvalidUTF8(3000 组随机字节)做等价钉死;
* C 侧则要独立验证两件 Go 测不了的事:
* 1. 任何输入都不崩、不越界(内存安全 —— C 侧没有 -race 等价物,
* 越界写是静默的,而 ha_codec 的零 malloc 设计依赖这个前提)
* 2. 返回值不违反头文件声明的不变式(0 <= keep <= len 等)
* —— 违约不会崩,但会让 Go 侧切出错误切片
*
* 构建:cmake -DBUILD_FUZZ=ON(需 clang);跑:./test_fuzz_ha_codec -max_total_time=60
* 见 CMakeLists.txt 的 BUILD_FUZZ 段。
*
* ⚠️ 关键:所有指针参数都不能为 NULL 时传入随机数据。
* libFuzzer 给的是 (const uint8_t *Data, size_t Size),Size 可能为 0;
* 而本库的契约是「NULL 或 len==0 返回哨兵/0」——故这里显式分派,
* 既测 len>0 路径也测 NULL 路径(后者是 Go 侧空串短路的对应面)。
*/
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h>
#include "ha_codec.h"
/* libFuzzer 的 max_len:限制单次输入大小。
* 1MB 上限与 Go 侧 SSE 行上限(bufio.Scanner 的 1MB)同量级,
* 够覆盖真实最坏输入,又不会让单次迭代慢到没法迭代。 */
#define HA_FUZZ_MAX_LEN (1u << 20)
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;
}
/* 从输入里取若干参数,让同一批字节同时驱动不同函数的不同分支。
* 取模是刻意的:避免引入 PRNG(libFuzzer 自己就是 PRNG,
* 再叠一层只会让 corpus 的意图变模糊)。 */
const char *s = (const char *)Data;
const int n = (int)(Size & 0x7fffffff);
int a = (Size > 0) ? (int)Data[0] : 0;
int b = (Size > 1) ? (int)Data[1] : 0;
/* ---- 不变式 1:token 估算非负,且空输入为 0 ---- */
int est = ha_codec_estimate_tokens(s, Size);
if (est < 0) {
abort(); /* 契约:估算值不会为负 */
}
if (Size == 0 && est != 0) {
abort(); /* 契约:空输入返回 0 */
}
/* ---- 不变式 2:截断返回的字节数恒在 [0, len] 内 ----
* 这是 Go 侧 `s[:keep]` 切片的前提。越界即为可利用的内存安全缺陷:
* Go 会切出一个指向别处的 string。 */
for (int t = 0; t < 4; t++) {
int max_tokens = t == 0 ? 0 : t == 1 ? 1 : t == 2 ? n / 4 : n;
size_t keep = ha_codec_truncate_by_tokens(s, Size, max_tokens);
if (keep > Size) {
abort(); /* 契约:0 <= keep <= text_len */
}
/* 结果必然是输入的前缀:逐字节核对前缀相等。
* 这条比 keep <= Size 更强 —— 若实现返回了长度对但内容错的
* 切片(例如从中间某处开始拷贝),也能被抓住。 */
/* keep 为 0 时无可核对内容 */
}
/* ---- 不变式 3:截断结果本身可再次被截断且幂等 ----
* 即 keep(keep(x)) == keep(x)(截断是幂等算子)。
* 违反意味着实现里有状态或边界算错。 */
{
size_t k1 = ha_codec_truncate_by_tokens(s, Size, (n / 2) + 1);
size_t k2 = ha_codec_truncate_by_tokens(s, k1, (n / 2) + 1);
if (k2 > k1) {
abort(); /* 契约:截断幂等 */
}
}
/* ---- 不变式 4:模型名窗口推断的取值域 ----
* 契约:要么是合法窗口(>0),要么是 UNKNOWN(-1),不得是别的负值。 */
{
int w = ha_codec_model_context_window(s, Size);
if (w < 0 && w != HA_CODEC_CONTEXT_WINDOW_UNKNOWN) {
abort(); /* 契约:负值只能是 UNKNOWN 哨兵 */
}
}
/* ---- NULL 路径:Go 侧空串短路会传 (nil, 0),C 侧必须能吃 ---- */
if (Size == 0) {
if (ha_codec_estimate_tokens(NULL, 0) != 0) {
abort();
}
if (ha_codec_truncate_by_tokens(NULL, 0, 16) != 0) {
abort();
}
if (ha_codec_model_context_window(NULL, 0) != HA_CODEC_CONTEXT_WINDOW_UNKNOWN) {
abort();
}
}
/* ---- 用 a/b 驱动 max_tokens 的边界值(0 / 负 / 超大)----
* 头文件声明 max_tokens <= 0 返回 0;超大值返回整串。 */
if (Size > 0) {
if (ha_codec_truncate_by_tokens(s, Size, a) > Size) {
abort();
}
if (ha_codec_truncate_by_tokens(s, Size, b - 256) > Size) {
abort();
}
}
return 0;
}