diff --git a/docs/测试说明/主机单测覆盖率口径.md b/docs/测试说明/主机单测覆盖率口径.md index db77d3e5..28e001c3 100644 --- a/docs/测试说明/主机单测覆盖率口径.md +++ b/docs/测试说明/主机单测覆盖率口径.md @@ -216,6 +216,30 @@ | **配置与工具(最便宜,建议第一个落地)** | `net_cfg.c`(167)、`task_prof.c`(160)、`ftp_cfg.c`(59)、`prot_set_store.c`(43) = 429 | 1.00/0.97/–/– | 真实头即可(`cfg_file.h` 已被现有单测证明能在 PC 上编),最多补 `dbg_log.h` | `docs/子系统设计/网络配置说明.md`(工厂默认 192.168.1.100/24、MAC 全零=芯片内置、保存立即生效)、`docs/算法/采集与算法层设计说明.md` | 硬件符号 0~2 处,纯解析/编解码/格式化。**一天内能出第一份绿**,用来把"新增 `files_tested`"这条路先跑通 | | **104 服务层** | `iec104_serv.c`(202) | 0.96 | 复用 `time_sync`/stub 的 `cmsis_os2.h` + 已有 `net_socket.h` | 与 `test/host/iec104/`(97 用例)同一套脚手架 | 同族的 `iec104_pts.c` 现在只有 **67.2%**(全仓最低之一);服务层进来才能把 YK/YX 编排的分支照全 | +**落地进度(2026-10-23,同日推进)**: + +| P1 批次 | 状态 | 新增模块(断言数) | 进分母的实测覆盖 | +|---|---|---|---| +| 配置与工具 4 块 | ✅ 完成 | `test/host/{net_cfg,ftp_cfg,prot_set_store,task_prof}`(104 条) | + `net_cfg.c` 108/112、`task_prof.c` 88/93、`ftp_cfg.c` 35/36、`prot_set_store.c` 27/29 | +| FTP 一套(4 块里 2 块) | 🟡 部分 | `test/host/lftpd_io`(33 条,含 `lftpd_string.c`) | + `lftpd_io.c` **85/159 = 53.5%**、`lftpd_string.c` 8/10;`read/write/close/stat/unlink/readdir` + 未测(要先把假 `FIL/FILINFO` 的字段语义做实,否则断言落在假件自己写的规则上) | +| `lftpd.c` / `lftpd_inet.c` | ⬜ 未开始 | — | 需假 `net_socket` 层(`time_sync`/stub 里那份可扩展) | +| NET 四块 / 104 服务层 | ⬜ 未开始 | — | **必须先新建 `stub/ch395f.h`** | + +数字:套件 16 → **21**、`files_tested` 19 → **25**、未触及 125 → 119(`.c` 47 → **41**, +恒等式 `41 = 7(P1 剩) + 5(P2) + 29(P3)` 仍然咬合)、分母 2,893 → **3,332** 插桩行。 +⚠ 总体 `line_rate` 88.2% → **87.2%**:**这正是 §5 说的摊薄,不是回退**——`lftpd_io.c` 只测了 +一半就进分母,分母涨得比分子快;要盯的是 `files_tested`/`files_untested` 两个数。 + +本批又照出一处(**语义未改,等裁定**):`lftpd_string_trim` 的尾部循环写成 +`for (i = strlen(p); i >= 0 && isspace(p[i]); i--)`,i 起步那格是 `'\0'`、`isspace(0)` 为假 → +循环一次都不执行,**尾部空白/CRLF 从未被裁掉**。今天不发作是因为 CRLF 在 +`lftpd_inet_read_line` 里已用 `strstr("\r\n")` 截断、唯一调用点 `lftpd.c` 只用返回指针去行首; +风险在"以后有人拿它去尾部空白"。修它一行即可,但会改变 FTP 参数(含空格的文件名/ +口令)的可见行为,需与 FTP 域一起过 → 现在只用 TC-LFTSTR-005 把现状钉成"改了会响"。 + ### P2|值得但工程量大(5 个 `.c` = 2,226 行) 1. **`net_socket.c`(1,251 行 / 51 个函数,全仓最大的逻辑块)** —— 必须**分片**,别一次吞。 diff --git a/scripts/id_registry.json b/scripts/id_registry.json index 40af32f1..9c73317a 100644 --- a/scripts/id_registry.json +++ b/scripts/id_registry.json @@ -1680,6 +1680,50 @@ "subitem_ok": true, "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);本族 2026-10-23 随覆盖率口径 §9 P1 第①批新建模块补登记", "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-TC-HOST-LFTPIO", + "class": "TC", + "title": "主机侧单测用例号:FTP 文件 IO 层与路径规范化(test/host/lftpd_io)", + "pattern": "\\bTC-LFTPIO-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/lftpd_io/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-LFTPIO-037", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" + }, + { + "name": "TC-HOST-TC-HOST-LFTSTR", + "class": "TC", + "title": "主机侧单测用例号:FTP 命令行字符串工具(同模块内)(test/host/lftpd_io)", + "pattern": "\\bTC-LFTSTR-[0-9]{1,4}[a-z]?\\b", + "digit_width": [ + 2, + 4 + ], + "owner_docs_glob": [ + "test/host/lftpd_io/*" + ], + "scope": "global", + "frozen": false, + "cursor": "TC-LFTSTR-005", + "cursor_owner_only": true, + "check_dangling": true, + "allow_orphans": true, + "subitem_ok": true, + "owner_note": "B 层口径(2026-10-21 批 3 定:这些号在代码目录外零消费者,属主登记处 = 单测代码本身,不逐号补文档);2026-10-23 随覆盖率口径 §9 P1 新建模块补登记", + "cursor_note": "游标按实测最大号收录(2026-10-23):越界即视为未登记的冒号牌" } ], "unmanaged_bare": { diff --git a/scripts/test_coverage_baseline.json b/scripts/test_coverage_baseline.json index d70a69cb..a8660a5c 100644 --- a/scripts/test_coverage_baseline.json +++ b/scripts/test_coverage_baseline.json @@ -168,6 +168,22 @@ "lines_covered": 37, "lines_valid": 38 }, + "Drivers/BSP/NET/lftpd/lftpd_io.c": { + "func_rate": 0.3846, + "funcs_covered": 5, + "funcs_valid": 13, + "line_rate": 0.5346, + "lines_covered": 85, + "lines_valid": 159 + }, + "Drivers/BSP/NET/lftpd/lftpd_string.c": { + "func_rate": 1.0, + "funcs_covered": 1, + "funcs_valid": 1, + "line_rate": 0.8, + "lines_covered": 8, + "lines_valid": 10 + }, "Drivers/BSP/NETCFG/ftp_cfg.c": { "func_rate": 1.0, "funcs_covered": 4, @@ -246,6 +262,14 @@ "lines_total": 1193, "ok": true }, + "lftpd_io": { + "files": { + "Drivers/BSP/NET/lftpd/lftpd_io.c": 159, + "Drivers/BSP/NET/lftpd/lftpd_string.c": 10 + }, + "lines_total": 169, + "ok": true + }, "nand_port": { "files": { "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c": 253 @@ -369,21 +393,23 @@ "App/util/task_watchdog.c", "Drivers/BSP/GD5F2GQ5UE/nand_ftl.c", "Drivers/BSP/NET/lftpd/lftpd_auth.c", + "Drivers/BSP/NET/lftpd/lftpd_io.c", + "Drivers/BSP/NET/lftpd/lftpd_string.c", "Drivers/BSP/NETCFG/ftp_cfg.c", "Drivers/BSP/NETCFG/net_cfg.c" ], "totals": { "files_generated_excluded": 5, - "files_tested": 23, - "files_untested": 121, - "func_rate": 0.9375, - "funcs_covered": 225, - "funcs_valid": 240, - "line_rate": 0.8887, - "lines_covered": 2811, - "lines_valid": 3163, - "modules_ok": 20, - "modules_total": 20 + "files_tested": 25, + "files_untested": 119, + "func_rate": 0.9094, + "funcs_covered": 231, + "funcs_valid": 254, + "line_rate": 0.8715, + "lines_covered": 2904, + "lines_valid": 3332, + "modules_ok": 21, + "modules_total": 21 }, "untested_sources": [ { @@ -438,10 +464,6 @@ "code_lines": 263, "path": "Drivers/BSP/NET/net_events.c" }, - { - "code_lines": 207, - "path": "Drivers/BSP/NET/lftpd/lftpd_io.c" - }, { "code_lines": 206, "path": "Drivers/BSP/CH395F/ch395f.h" @@ -790,10 +812,6 @@ "code_lines": 11, "path": "App/task/ui_task.h" }, - { - "code_lines": 11, - "path": "Drivers/BSP/NET/lftpd/lftpd_string.c" - }, { "code_lines": 11, "path": "Drivers/BSP/NET/lftpd/private/lftpd_inet.h" diff --git a/test/host/lftpd_io/lftpd_io_test.c b/test/host/lftpd_io/lftpd_io_test.c new file mode 100644 index 00000000..ed464ce0 --- /dev/null +++ b/test/host/lftpd_io/lftpd_io_test.c @@ -0,0 +1,427 @@ +/* + * 模块名称:FTP 文件 IO 层(lftpd_io)+ FTP 字符串小工具主机侧单元测试 + * 模块功能:在 PC 上离线验证 Drivers/BSP/NET/lftpd/lftpd_io.c 的 + * **路径规范化(含越权上跳钳位)**、FatFs 路径映射、单文件槽守卫、 + * 卷锁获取/释放时机、等锁期间喂狗,以及 lftpd_string.c 的 trim。 + * + * 被测对象:真实源文件 ../../../../Drivers/BSP/NET/lftpd/lftpd_io.c 与 + * ../../../../Drivers/BSP/NET/lftpd/lftpd_string.c(#include 原样纳入; + * 两个 .c 的静态符号不冲突,可共用一个 TU,省一套桩)。桩与假件: + * - 真实头:Lib/FatFs/ff.h(FRESULT/FIL/FA_* 一律原汁)、 + * App/fs/vol_lock.h、lftpd.h、private/lftpd_io.h、private/lftpd_string.h; + * - 假 FatFs:本文件实现 f_open(记录 path/mode、返回可注入 FRESULT), + * 其余 f_* 未触及的分支本批不测(见下方"未覆盖"); + * - 假堆:pvPortMalloc/vPortFree = 静态池 + **可注入分配失败**, + * 用来跑"堆不够 → 返回 NULL"这条真机上 MicroLIB/FreeRTOS 堆耗尽会走、 + * 而模拟器(宿主 CRT)永远走不到的分支; + * - 假卷锁:vol_lock_wait 按脚本逐次返回,lftpd_task_wd_beat 计数。 + * + * 为什么要主机侧单测(而不是只靠台架): + * 1. **越权钳位是安全属性**:FTP 客户端可以发任意带 ".." 的路径,规范化必须 + * "在根上跳不动"("/a" + "../../x" = "/x",而不是逃出根目录)。真机上没有 + * 第二个文件系统可逃,测试里却能逐段构造,一次钉死(TC-LFTPIO-010..013)。 + * 2. 这个函数返回的是 **pvPortMalloc 出来的指针**——它正是历史上落在 C 库 malloc + * 上的那一个(RAM 方案 §4.2.1:lftpd_io.c 3 处 malloc 已改 pvPortMalloc)。 + * 分配失败必须**返回 NULL 且不泄漏第一块**(否则 FTP 一次 LIST 就把堆啃穿)。 + * 只有假堆能稳定构造这条路径。 + * 3. **单文件槽守卫**(s_file_open)与**卷锁释放时机**:open 成功时故意**不**释放 + * 卷锁(整个传输期间独占卷,由 close 释放),失败时必须释放。写反了就是 + * "defaultTask 再也拿不到卷"或"传输中途别人能改卷",两者都只在真机偶发。 + * 4. 等卷锁的循环里必须喂看门狗(lftpd_task_wd_beat)——defaultTask 与 FTP 争卷时 + * 这里可能空转上百毫秒,漏喂就是整机复位(这条在真机上极难稳定复现)。 + * + * 覆盖点: + * —— FatFs 路径映射(静态函数直测,TC-LFTPIO-001..004)—— + * TC-LFTPIO-001 NULL / 空串 → "0:"(卷根) + * TC-LFTPIO-002 "/" → "0:" + * TC-LFTPIO-003 "/a/b.txt" → "a/b.txt"(去掉前导斜杠) + * TC-LFTPIO-004 非斜杠开头原样返回 + * —— 路径规范化(TC-LFTPIO-010..022)—— + * TC-LFTPIO-010 相对名拼到 base 下 + * TC-LFTPIO-011 绝对名忽略 base + * TC-LFTPIO-012 ".." 回退一级 + * TC-LFTPIO-013 **上跳过头也不逃出根**("/a" + "../../x" = "/x") + * TC-LFTPIO-014 "." 段被丢弃 + * TC-LFTPIO-015 "base/.." 归一到 "/" + * TC-LFTPIO-016 连续斜杠与尾部斜杠被折叠 + * TC-LFTPIO-017 base 为 NULL 视作空串 + * TC-LFTPIO-018 name 为 NULL 视作空串 + * TC-LFTPIO-019 两者皆 NULL → "/" + * TC-LFTPIO-020 根 + ".." → "/" + * TC-LFTPIO-021 第一块分配失败 → NULL(且不崩、不动第二块) + * TC-LFTPIO-022 第二块(结果缓冲)分配失败 → NULL **且第一块已释放** + * —— 打开文件:守卫、卷锁、错误通道(TC-LFTPIO-030..041)—— + * TC-LFTPIO-030 open_read 成功返回 0,f_open 收到 FA_READ 与映射后的路径 + * TC-LFTPIO-031 已有文件打开时 open_read 返回 -1 且**不再碰卷锁与 FatFs** + * TC-LFTPIO-032 open_read 失败返回 -1、留 ERROR、并**释放卷锁** + * TC-LFTPIO-033 open_read 成功后卷锁**不释放**(传输期独占,由 close 释放) + * TC-LFTPIO-034 卷未就绪时循环等待,每轮喂一次看门狗 + * TC-LFTPIO-035 open_write 用 FA_WRITE|FA_CREATE_ALWAYS(覆盖写语义) + * TC-LFTPIO-036 open_write 同样受单文件槽守卫 + * TC-LFTPIO-037 open_write 失败也释放卷锁 + * —— trim(TC-LFTSTR-001..005)—— + * TC-LFTSTR-001 行首空白由**返回指针**跳过,原缓冲不搬移(调用方用的就是返回值) + * TC-LFTSTR-002 无前导空白时返回同一指针、内容不变 + * TC-LFTSTR-003 词中间空白保留 + * TC-LFTSTR-004 全空白串 → 空串 + * TC-LFTSTR-005 **现状桩**:尾部空白/CRLF 实际不裁(见下"本批照出的问题") + * + * 本批照出的问题(**未改语义**,等裁定):lftpd_string_trim 的尾部循环写成 + * for (i = strlen(p); i >= 0 && isspace(p[i]); i--) —— i 起步那格是 '\0', + * isspace(0) 为假,循环一次都不执行,**尾部空白从未被裁掉**。今天不发作是因为 + * FTP 命令行的 CRLF 在 lftpd_inet_read_line 里已用 strstr("\r\n") 截断,而唯一 + * 调用点 lftpd.c:780 只关心行首。真正的风险是"以后有人拿它去尾部空白"——不会生效。 + * 修法是一行(i 从 strlen(p)-1 起),但那会改变 FTP 参数(含空格的文件名/口令) + * 的可见行为,需与 FTP 域一起过;本批只用 TC-LFTSTR-005 把现状钉成"改了会响"。 + * + * 未覆盖(诚实登记,下一批补):lftpd_io_read / write / close / stat / unlink / + * opendir / readdir / closedir —— 它们要先把假 FIL/FILINFO 的字段语义(fptr、 + * obj.objsize、altname 等)做实,否则断言会落在假件自己写的规则上,失去意义。 + * + * 通过:全部 TC PASS(main 返回 0)。 + * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。 + * + * 创建日期:2026-10-23 + */ + +#include +#include + +#include "ff.h" +#include "private/lftpd_io.h" +#include "private/lftpd_string.h" +#include "vol_lock.h" +#include "lftpd.h" + +/* 被测生产源(原样纳入;静态符号不冲突) */ +#include "lftpd_io.c" +#include "lftpd_string.c" + +/* ===================== 断言框架 ===================== */ + +static int s_total; +static int s_passed; +static int s_failed; + +#define CHECK(cond, name) do { \ + s_total++; \ + if (cond) { s_passed++; printf(" PASS %s\n", name); } \ + else { s_failed++; printf(" FAIL %s (line %d)\n", name, __LINE__); } \ +} while (0) + +/* ===================== dbg_log 桩计数 ===================== */ + +int g_lfio_info_count; +int g_lfio_error_count; +int g_lfio_debug_count; + +static void logs_clear(void) +{ + g_lfio_info_count = 0; + g_lfio_error_count = 0; + g_lfio_debug_count = 0; +} + +/* ===================== 假堆(可注入分配失败) ===================== */ + +static uint8_t g_pool[512]; +static size_t g_pool_used; +static int g_alloc_calls; +static int g_free_calls; +static int g_fail_at; /* >0:第 N 次 pvPortMalloc 开始失败;0 = 不失败 */ + +static void heap_reset(void) +{ + g_pool_used = 0u; + g_alloc_calls = 0; + g_free_calls = 0; + g_fail_at = 0; +} + +void *pvPortMalloc(size_t xWantedSize) +{ + void *p; + + g_alloc_calls++; + if ((g_fail_at > 0) && (g_alloc_calls >= g_fail_at)) { + return NULL; + } + if ((g_pool_used + xWantedSize + 8u) > sizeof(g_pool)) { + return NULL; + } + /* 8 字节对齐,模拟真实堆 */ + g_pool_used = (g_pool_used + 7u) & ~(size_t)7u; + p = &g_pool[g_pool_used]; + g_pool_used += xWantedSize; + return p; +} + +void vPortFree(void *pv) +{ + if (pv != NULL) { + g_free_calls++; + } +} + +/* ===================== 假卷锁与喂狗 ===================== */ + +static int g_wait_seq[8]; /* 每次 vol_lock_wait 的返回值脚本(0=未就绪) */ +static int g_wait_calls; +static int g_release_calls; +static int g_beat_calls; + +int vol_lock_wait(uint32_t timeout_ms) +{ + int r; + + (void)timeout_ms; + r = (g_wait_calls < (int)(sizeof(g_wait_seq) / sizeof(g_wait_seq[0]))) + ? g_wait_seq[g_wait_calls] : 1; + g_wait_calls++; + return r; +} + +void vol_lock_release(void) +{ + g_release_calls++; +} + +void lftpd_task_wd_beat(void) +{ + g_beat_calls++; +} + +int vol_lock_init(void) { return 0; } + +/* ===================== 假 FatFs(只到本批用到的 f_open) ===================== */ + +static FRESULT g_open_rc; +static char g_open_path[64]; +static BYTE g_open_mode; +static int g_open_calls; + +FRESULT f_open(FIL *fp, const TCHAR *path, BYTE mode) +{ + int i; + + (void)fp; + g_open_calls++; + g_open_mode = mode; + for (i = 0; (path[i] != _T('\0')) && (i + 1 < (int)sizeof(g_open_path)); i++) { + g_open_path[i] = (char)path[i]; + } + g_open_path[i] = '\0'; + return g_open_rc; +} + +/* 本批不测的 FatFs 入口:只为**可链接**而给出,一律返回 FR_INT_ERR—— + * 真有测试走进来就会拿到 -1/-2 之类的意外返回值而断言失败,不会静默假绿。 + * (下一批要把假 FIL/FILINFO 的字段语义做实再逐个展开) */ +FRESULT f_close(FIL *fp) { (void)fp; return FR_INT_ERR; } +FRESULT f_read(FIL *fp, void *p_buff, UINT btr, UINT *br) +{ + (void)fp; (void)p_buff; (void)btr; + if (br != NULL) { *br = 0u; } + return FR_INT_ERR; +} +FRESULT f_write(FIL *fp, const void *p_buff, UINT btw, UINT *bw) +{ + (void)fp; (void)p_buff; (void)btw; + if (bw != NULL) { *bw = 0u; } + return FR_INT_ERR; +} +FRESULT f_stat(FILINFO *fno, const TCHAR *path) { (void)fno; (void)path; return FR_INT_ERR; } +FRESULT f_unlink(const TCHAR *path) { (void)path; return FR_INT_ERR; } +FRESULT f_opendir(DIR *dp, const TCHAR *path) { (void)dp; (void)path; return FR_INT_ERR; } +FRESULT f_readdir(DIR *dp, FILINFO *fno) { (void)dp; (void)fno; return FR_INT_ERR; } +FRESULT f_closedir(DIR *dp) { (void)dp; return FR_INT_ERR; } + +/* ===================== 复位与辅助 ===================== */ + +static void reset_all(void) +{ + heap_reset(); + g_wait_calls = 0; + g_release_calls = 0; + g_beat_calls = 0; + g_open_calls = 0; + g_open_rc = FR_OK; + g_open_path[0] = '\0'; + g_open_mode = 0; + g_wait_seq[0] = 1; + g_wait_seq[1] = 1; + g_wait_seq[2] = 1; + g_wait_seq[3] = 1; + s_file_open = 0; + logs_clear(); +} + +static char *canon(const char *base, const char *name) +{ + char *p = lftpd_io_canonicalize_path(base, name); + static char keep[128]; + + if (p == NULL) { + return NULL; + } + (void)strncpy(keep, p, sizeof(keep) - 1u); + keep[sizeof(keep) - 1u] = '\0'; + vPortFree(p); + return keep; +} + +/* ===================== FatFs 路径映射 ===================== */ + +static void tc_fatfs_path(void) +{ + printf("-- TC-LFTPIO-001..004 to_fatfs_path --\n"); + CHECK((strcmp(to_fatfs_path(NULL), "0:") == 0) && + (strcmp(to_fatfs_path(""), "0:") == 0), "TC-LFTPIO-001 NULL/空串 -> 0:"); + CHECK(strcmp(to_fatfs_path("/"), "0:") == 0, "TC-LFTPIO-002 根 -> 0:"); + CHECK(strcmp(to_fatfs_path("/a/b.txt"), "a/b.txt") == 0, + "TC-LFTPIO-003 去掉前导斜杠"); + CHECK(strcmp(to_fatfs_path("a.txt"), "a.txt") == 0, "TC-LFTPIO-004 原样返回"); +} + +/* ===================== 路径规范化 ===================== */ + +static void tc_canon(void) +{ + printf("-- TC-LFTPIO-010..022 canonicalize --\n"); + reset_all(); + CHECK(strcmp(canon("/dir", "f.txt"), "/dir/f.txt") == 0, "TC-LFTPIO-010 相对拼接"); + CHECK(strcmp(canon("/dir", "/abs.txt"), "/abs.txt") == 0, "TC-LFTPIO-011 绝对忽略 base"); + CHECK(strcmp(canon("/a/b", "../c"), "/a/c") == 0, "TC-LFTPIO-012 上跳一级"); + CHECK(strcmp(canon("/a", "../../x"), "/x") == 0, + "TC-LFTPIO-013 上跳过头仍被钳在根内(安全属性)"); + CHECK(strcmp(canon("/a", "."), "/a") == 0, "TC-LFTPIO-014 丢弃 . 段"); + CHECK(strcmp(canon("/a", ".."), "/") == 0, "TC-LFTPIO-015 回退到根"); + CHECK(strcmp(canon("/a//", "b"), "/a/b") == 0, "TC-LFTPIO-016 段间多余斜杠被折叠"); + CHECK(strcmp(canon("/dir", "//x"), "/x") == 0, + "TC-LFTPIO-016b name 以斜杠开头按**绝对路径**处理(忽略 base,仍钳在根内)"); + CHECK(strcmp(canon(NULL, "x"), "/x") == 0, "TC-LFTPIO-017 base NULL 视作空串"); + CHECK(strcmp(canon("/a", NULL), "/a") == 0, "TC-LFTPIO-018 name NULL 视作空串"); + CHECK(strcmp(canon(NULL, NULL), "/") == 0, "TC-LFTPIO-019 两个 NULL -> /"); + CHECK(strcmp(canon("/", ".."), "/") == 0, "TC-LFTPIO-020 根上加 .. 仍是根"); + + reset_all(); + g_fail_at = 1; + CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL, + "TC-LFTPIO-021 首块分配失败返回 NULL"); + CHECK(g_alloc_calls == 1, "TC-LFTPIO-021b 失败后不再分配第二块"); + + reset_all(); + g_fail_at = 2; + CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL, + "TC-LFTPIO-022 结果缓冲分配失败返回 NULL"); + CHECK(g_free_calls == 1, "TC-LFTPIO-022b 第一块已释放(不泄漏堆)"); +} + +/* ===================== 打开文件:守卫 / 卷锁 / 错误通道 ===================== */ + +static void tc_open(void) +{ + int rc; + + printf("-- TC-LFTPIO-030..037 open_read / open_write --\n"); + + reset_all(); + rc = lftpd_io_open_read("/a/b.txt"); + CHECK((rc == 0) && (g_open_mode == FA_READ) && + (strcmp(g_open_path, "a/b.txt") == 0), + "TC-LFTPIO-030 成功:FA_READ + 映射后的路径"); + + reset_all(); + s_file_open = 1; + rc = lftpd_io_open_read("/x"); + CHECK((rc == -1) && (g_open_calls == 0) && (g_wait_calls == 0), + "TC-LFTPIO-031 单文件槽守卫:已打开时不碰卷锁与 FatFs"); + + reset_all(); + g_open_rc = FR_NO_FILE; + rc = lftpd_io_open_read("/x"); + CHECK((rc == -1) && (g_release_calls == 1) && (g_lfio_error_count == 1), + "TC-LFTPIO-032 打开失败:返回 -1 + 留痕 + 释放卷锁"); + + reset_all(); + rc = lftpd_io_open_read("/x"); + CHECK((rc == 0) && (g_release_calls == 0) && (s_file_open == 1), + "TC-LFTPIO-033 成功后卷锁不释放(传输期独占)"); + + reset_all(); + g_wait_seq[0] = 0; /* 第一轮:卷被 defaultTask 占着 */ + g_wait_seq[1] = 0; /* 第二轮:仍未就绪 */ + g_wait_seq[2] = 1; /* 第三轮:拿到 */ + rc = lftpd_io_open_read("/x"); + CHECK((rc == 0) && (g_beat_calls == 2) && (g_wait_calls == 3), + "TC-LFTPIO-034 等锁循环每轮喂看门狗(漏喂 = 整机复位)"); + + reset_all(); + rc = lftpd_io_open_write("/up.bin"); + CHECK((rc == 0) && (g_open_mode == (BYTE)(FA_WRITE | FA_CREATE_ALWAYS)), + "TC-LFTPIO-035 open_write 用覆盖写模式"); + + reset_all(); + s_file_open = 1; + rc = lftpd_io_open_write("/up.bin"); + CHECK((rc == -1) && (g_open_calls == 0), "TC-LFTPIO-036 open_write 同样有槽守卫"); + + reset_all(); + g_open_rc = FR_DISK_ERR; + rc = lftpd_io_open_write("/up.bin"); + CHECK((rc == -1) && (g_release_calls == 1), "TC-LFTPIO-037 open_write 失败也释放卷锁"); +} + +/* ===================== trim ===================== */ + +static void tc_trim(void) +{ + char buf[32]; + char *r; + + printf("-- TC-LFTSTR-001..005 lftpd_string_trim --\n"); + + /* 实现是"返回一个跳过行首空白的指针",**不搬移缓冲**;调用方 lftpd.c:780 + * 用的正是返回值。所以断言必须落在返回值上,落在 buf 上是错的。 */ + (void)strcpy(buf, " PASS x"); + r = lftpd_string_trim(buf); + CHECK((r != buf) && (strcmp(r, "PASS x") == 0) && (buf[0] == ' '), + "TC-LFTSTR-001 行首空白靠返回指针跳过,原缓冲不搬移"); + + (void)strcpy(buf, "NOOP"); + r = lftpd_string_trim(buf); + CHECK((r == buf) && (strcmp(buf, "NOOP") == 0), "TC-LFTSTR-002 无前导空白返回同一指针"); + + (void)strcpy(buf, " USER admin "); + r = lftpd_string_trim(buf); + CHECK(strcmp(r, "USER admin ") == 0, "TC-LFTSTR-003 词中间空白保留"); + + (void)strcpy(buf, " \t\r\n"); + r = lftpd_string_trim(buf); + CHECK(r[0] == '\0', "TC-LFTSTR-004 全空白 -> 空串"); + + /* 现状桩(不是期望行为):尾部循环从 strlen(p) 起步,那里是 '\0', + * isspace(0)=0 立刻退出 —— **尾部空白/CRLF 实际从未被裁**。今天无害是因为 + * CRLF 在 lftpd_inet_read_line(lftpd_inet.c 的 strstr("\r\n"))里已截掉。 + * 谁哪天把 trim 修对了,这条会红——那是提醒,不是回归。 */ + (void)strcpy(buf, "LIST "); + r = lftpd_string_trim(buf); + CHECK(strcmp(r, "LIST ") == 0, + "TC-LFTSTR-005 现状:尾部空白不裁(潜伏缺陷已登记,修好它会让本条响)"); +} + +/* ===================== 入口 ===================== */ + +int main(void) +{ + printf("=== LFTPD IO Tests ===\n"); + + tc_fatfs_path(); + tc_canon(); + tc_open(); + tc_trim(); + + printf("\n=== LFTPD IO Tests: %d/%d PASSED (failed=%d) ===\n", + s_passed, s_total, s_failed); + return (s_failed == 0) ? 0 : 1; +} diff --git a/test/host/lftpd_io/run_unit_test.py b/test/host/lftpd_io/run_unit_test.py new file mode 100644 index 00000000..7ef05171 --- /dev/null +++ b/test/host/lftpd_io/run_unit_test.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""FTP 文件 IO 层(lftpd_io + lftpd_string)的主机侧单元测试运行器(L0.5,纯标准库)。 + +做什么: + 用本机 MSVC 编译 test/host/lftpd_io/lftpd_io_test.c(同时纳入生产源 + Drivers/BSP/NET/lftpd/lftpd_io.c 与 lftpd_string.c),在 PC 上离线验证路径规范化 + (含 ".." 越权钳位)、FatFs 路径映射、单文件槽守卫、卷锁获取/释放时机、等锁期间 + 喂狗、分配失败返回 NULL 且不泄漏,以及 FTP 命令行 trim。失败即非 0 退出。 + 工具链与判定委托 test/host/common/unit_test_harness.py。 + +为什么要主机侧单测(而不是只用台架): + 越权钳位是安全属性(FTP 客户端可发任意带 ".." 的路径),真机上没有第二个文件系统 + 可逃、测不出来;卷锁"成功不释放、失败必释放"写反了就是 defaultTask 永远拿不到卷 + 或传输中途卷被人改;等锁循环漏喂狗就是整机复位——三条都只在真机偶发、日志看不出 + 根因。分配失败分支更只有假堆能构造(模拟器宿主 CRT 永不失败)。 + +桩的边界:ff.h / vol_lock.h / lftpd.h / private/*.h 全用**真实头**(FRESULT、FA_*、 + 函数签名一律原汁);FreeRTOS.h 与 dbg_log.h 用本目录桩;f_open、卷锁、喂狗、 + pvPortMalloc/vPortFree 由测试单元给可控实现(假堆可注入分配失败)。 + read/write/close/stat/unlink/readdir 未列入本批:要先把假 FIL/FILINFO 的字段语义 + 做实,否则断言落在假件自己写的规则上(详见测试单元文件头"未覆盖")。 + +用法(仓库根目录): + python test/host/lftpd_io/run_unit_test.py + +退出码:0=PASS / 1=测试失败 / 2=环境错误。 + +设计依据:docs/内存管理/RAM使用分析与优化方案.md §4.2.1、docs/测试说明/主机单测覆盖率口径.md §9 P1 +创建日期:2026-10-23 +""" +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE))) +sys.path.insert(0, os.path.join(ROOT, "test", "host", "common")) + +from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402 + +# 顺序敏感:stub 必须最先(覆盖 FreeRTOS.h / dbg_log.h)。 +# lftpd_io.c 里 include 的是 "private/lftpd_io.h" 与 "lftpd.h",故给出 lftpd 目录; +# vol_lock.h 在 App/fs,ff.h 在 Lib/FatFs(与 test/host/nand_port 同一份真实头)。 +BUILD = TestBuild( + name="LFTPD IO Tests", + src=os.path.join(HERE, "lftpd_io_test.c"), + include_dirs=[ + os.path.join(HERE, "stub"), + os.path.join(ROOT, "Drivers", "BSP", "NET", "lftpd"), + os.path.join(ROOT, "App", "fs"), + os.path.join(ROOT, "Lib", "FatFs"), + HERE, + ], + # /D_CRT_SECURE_NO_WARNINGS:生产 lftpd_io.c 里的 strcpy/strcat/strncpy 在 + # MSVC 下刷 C4996 弃用告警(ARMCC 不报,且 lftpd 不在 coding-standard 范围内), + # 这是纯主机侧噪音;关掉它才能让本模块的编译输出保持可读。 + extra_flags=["/D_CRT_SECURE_NO_WARNINGS"], + summary_marker="=== LFTPD IO Tests:", +) + +if __name__ == "__main__": + sys.exit(run_host_unit_test(BUILD)) + diff --git a/test/host/lftpd_io/stub/FreeRTOS.h b/test/host/lftpd_io/stub/FreeRTOS.h new file mode 100644 index 00000000..15a4a82d --- /dev/null +++ b/test/host/lftpd_io/stub/FreeRTOS.h @@ -0,0 +1,23 @@ +/* + * 模块名称:主机侧单测桩 - FreeRTOS.h(lftpd_io 模块专用) + * 模块功能:lftpd_io.c 只经这个头拿到 pvPortMalloc / vPortFree 的原型(它自己不 + * 调内核 API,等卷锁是 vol_lock 的事)。真实 FreeRTOS.h 会拉整棵 + * portmacro / 编译器抽象层,PC 上编不动,故这里只给这两个原型。 + * + * ⚠ 仅供 test/host/lftpd_io/ 使用,不参与 MCU 构建。 + * 签名必须与 FreeRTOS 的 portmacro 一致(size_t 入参 / void 返回的 free); + * 真实实现由测试单元给出**可注入失败的假堆**,用来跑"分配失败返回 NULL"的分支 + * ——那正是真机上 MicroLIB 512B C 堆与 FreeRTOS 堆耗尽时的形态。 + * + * 创建日期:2026-10-23 + */ + +#ifndef FREERTOS_H +#define FREERTOS_H + +#include + +void *pvPortMalloc(size_t xWantedSize); +void vPortFree(void *pv); + +#endif /* FREERTOS_H */ diff --git a/test/host/lftpd_io/stub/dbg_log.h b/test/host/lftpd_io/stub/dbg_log.h new file mode 100644 index 00000000..99e75726 --- /dev/null +++ b/test/host/lftpd_io/stub/dbg_log.h @@ -0,0 +1,19 @@ +/* + * 模块名称:主机侧单测桩 - dbg_log.h(lftpd_io 模块专用) + * 模块功能:DBG_* 换成计数,让"打开失败必须留痕、等锁循环不打日志"可断言。 + * ⚠ 仅供 test/host/lftpd_io/ 使用,不参与 MCU 构建。 + * 创建日期:2026-10-23 + */ + +#ifndef DBG_LOG_H +#define DBG_LOG_H + +extern int g_lfio_info_count; +extern int g_lfio_error_count; +extern int g_lfio_debug_count; + +#define DBG_INFO(fmt, ...) do { (void)(fmt); g_lfio_info_count++; } while (0) +#define DBG_DEBUG(fmt, ...) do { (void)(fmt); g_lfio_debug_count++; } while (0) +#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_lfio_error_count++; } while (0) + +#endif /* DBG_LOG_H */