/* * 模块名称: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 实际不裁(见下"本批照出的问题") * * **处置(2026-10-23 用户裁定 = 改名)**: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; static uint32_t s_last_wait_to; /* 最近一次 vol_lock_wait 的 timeout 入参。字段名与另三套假件 * 逐字一致(本文件其余假件用 g_ 前缀,这里让"喂狗点绝不阻塞" * 这条纪律能一次 grep 到四套);第 6 套出现时再议共享假件 */ int vol_lock_wait(uint32_t timeout_ms) { int r; s_last_wait_to = timeout_ms; /* 记录入参:本套的口径是 100 ms 分片,不是 try */ 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; s_last_wait_to = 0xFFFFFFFFu; /* 哨兵:从未调用过就不等于 timeout=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/empty string -> 0:"); CHECK(strcmp(to_fatfs_path("/"), "0:") == 0, "TC-LFTPIO-002 root -> 0:"); CHECK(strcmp(to_fatfs_path("/a/b.txt"), "a/b.txt") == 0, "TC-LFTPIO-003 the leading slash is stripped"); CHECK(strcmp(to_fatfs_path("a.txt"), "a.txt") == 0, "TC-LFTPIO-004 returned unchanged"); } /* ===================== 路径规范化 ===================== */ 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 relative join"); CHECK(strcmp(canon("/dir", "/abs.txt"), "/abs.txt") == 0, "TC-LFTPIO-011 an absolute name ignores base"); CHECK(strcmp(canon("/a/b", "../c"), "/a/c") == 0, "TC-LFTPIO-012 go up one level"); CHECK(strcmp(canon("/a", "../../x"), "/x") == 0, "TC-LFTPIO-013 over-shooting .. is still clamped inside the root (safety property)"); CHECK(strcmp(canon("/a", "."), "/a") == 0, "TC-LFTPIO-014 a . segment is dropped"); CHECK(strcmp(canon("/a", ".."), "/") == 0, "TC-LFTPIO-015 .. at the top falls back to the root"); CHECK(strcmp(canon("/a//", "b"), "/a/b") == 0, "TC-LFTPIO-016 redundant slashes between segments are collapsed"); CHECK(strcmp(canon("/dir", "//x"), "/x") == 0, "TC-LFTPIO-016b a name starting with a slash is treated as an absolute path (base ignored, still clamped inside the root)"); CHECK(strcmp(canon(NULL, "x"), "/x") == 0, "TC-LFTPIO-017 a NULL base counts as an empty string"); CHECK(strcmp(canon("/a", NULL), "/a") == 0, "TC-LFTPIO-018 a NULL name counts as an empty string"); CHECK(strcmp(canon(NULL, NULL), "/") == 0, "TC-LFTPIO-019 both NULL -> /"); CHECK(strcmp(canon("/", ".."), "/") == 0, "TC-LFTPIO-020 .. on the root is still the root"); reset_all(); g_fail_at = 1; CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL, "TC-LFTPIO-021 the first allocation failing returns NULL"); CHECK(g_alloc_calls == 1, "TC-LFTPIO-021b no second allocation is attempted after that failure"); reset_all(); g_fail_at = 2; CHECK(lftpd_io_canonicalize_path("/a", "b") == NULL, "TC-LFTPIO-022 the result buffer allocation failing returns NULL"); CHECK(g_free_calls == 1, "TC-LFTPIO-022b the first block was freed (no heap leak)"); } /* ===================== 打开文件:守卫 / 卷锁 / 错误通道 ===================== */ 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 success: FA_READ plus the mapped path"); 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 single-file slot guard: while a file is open it touches neither the volume lock nor 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 open failure: return -1 + log + release the volume lock"); reset_all(); rc = lftpd_io_open_read("/x"); CHECK((rc == 0) && (g_release_calls == 0) && (s_file_open == 1), "TC-LFTPIO-033 the volume lock stays held after success (exclusive for the whole transfer)"); 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) && (s_last_wait_to == 100u), "TC-LFTPIO-034 the lock-wait loop feeds the watchdog every round (missing a feed resets the whole device) + every slice asks timeout=100ms (never try, never forever)"); 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 uses overwrite mode"); 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 has the same slot guard"); 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 an open_write failure also releases the volume lock"); } /* ===================== trim ===================== */ static void tc_trim(void) { char buf[32]; char *r; printf("-- TC-LFTSTR-001..005 lftpd_string_ltrim --\n"); /* 实现是"返回一个跳过行首空白的指针",**不搬移缓冲**;调用方 lftpd.c:780 * 用的正是返回值。所以断言必须落在返回值上,落在 buf 上是错的。 */ (void)strcpy(buf, " PASS x"); r = lftpd_string_ltrim(buf); CHECK((r != buf) && (strcmp(r, "PASS x") == 0) && (buf[0] == ' '), "TC-LFTSTR-001 leading blanks are skipped via the returned pointer, the buffer itself is not moved"); (void)strcpy(buf, "NOOP"); r = lftpd_string_ltrim(buf); CHECK((r == buf) && (strcmp(buf, "NOOP") == 0), "TC-LFTSTR-002 with no leading blanks the same pointer comes back"); (void)strcpy(buf, " USER admin "); r = lftpd_string_ltrim(buf); CHECK(strcmp(r, "USER admin ") == 0, "TC-LFTSTR-003 blanks inside the text are kept"); (void)strcpy(buf, " \t\r\n"); r = lftpd_string_ltrim(buf); CHECK(r[0] == '\0', "TC-LFTSTR-004 all blanks -> empty string"); /* 现状桩(不是期望行为):尾部循环从 strlen(p) 起步,那里是 '\0', * isspace(0)=0 立刻退出 —— **尾部空白/CRLF 实际从未被裁**。今天无害是因为 * CRLF 在 lftpd_inet_read_line(lftpd_inet.c 的 strstr("\r\n"))里已截掉。 * 谁哪天把 trim 修对了,这条会红——那是提醒,不是回归。 */ /* 2026-10-23 处置:函数已由 lftpd_string_trim 改名为 lftpd_string_ltrim, * 并删掉那条永不执行的尾部循环。于是"不去尾部"从潜伏陷阱变成**写进名字与注释 * 的契约**:RFC 959 里参数的尾随空格属于参数本体(PASS 口令、含空格的文件名), * 去掉等于静默改数据;CRLF 由 lftpd_inet_read_line 截断。 */ (void)strcpy(buf, "LIST "); r = lftpd_string_ltrim(buf); CHECK(strcmp(r, "LIST ") == 0, "TC-LFTSTR-005 trailing spaces must survive (ltrim semantics, they belong to the RFC 959 argument)"); } /* ===================== 入口 ===================== */ 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; }