diff --git a/patch_v2/reader_app/.gitignore b/patch_v2/reader_app/.gitignore index edaab6f..a3c9fb8 100644 --- a/patch_v2/reader_app/.gitignore +++ b/patch_v2/reader_app/.gitignore @@ -11,6 +11,8 @@ /test/font_resolve_smoke_host /test/font_resolve_bench_host /test/shelf_scan_bench_host +/test/shelf_index_smoke_host +/test/shelf_index_bench_host /test/shelf_scan_bench_mipsel /test/rdft_smoke_mipsel /test/book_smoke_mipsel diff --git a/patch_v2/reader_app/app.json b/patch_v2/reader_app/app.json index 0f3ec97..74e61f8 100644 --- a/patch_v2/reader_app/app.json +++ b/patch_v2/reader_app/app.json @@ -1 +1 @@ -{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.4","version_code":5,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true} +{"name":"Reader","exec":"/usr/data/mnt/sd_0/MeowAPPs/os.meow.reader/reader","package":"os.meow.reader","version":"0.1.5","version_code":6,"framework":1,"fonts":"common","author":"MeowOS","icon":"icon.png","volume_page":true} diff --git a/patch_v2/reader_app/app/shelf.c b/patch_v2/reader_app/app/shelf.c index 65ffa2b..3ec8dde 100644 --- a/patch_v2/reader_app/app/shelf.c +++ b/patch_v2/reader_app/app/shelf.c @@ -22,11 +22,17 @@ * M30:M29 的「刚读完不置顶」缺口补上(只刷一本书的 ts + 内存重排,不重扫全盘)。 * M31:置顶时一并重算这本的副题(shelf_fmt_sub),「· 读」立即出现,不等下一次全量刷新; * 顶栏「刷新」同时丢弃字体路径缓存(新拷入的字库此时生效)。 + * M32:书架索引落盘(app/shelf_index.c)。冷启动先试 `MeowData/os.meow.reader/index/shelf.idx`: + * 版本/CRC/root 路径/全部目录 mtime 校验通过 → 直接出书,**完全跳过 readdir** + * (真机全量扫描 3.0s 的 96% 是 434 个目录的 opendir+readdir;校验只做 434 次 stat); + * 任何异常 → 照常全量重扫(fail-open)。扫描完成后原子写回索引(写失败只丢缓存,不影响书架)。 + * M29 语义不变:首次进书架=索引或全扫;从阅读页返回=不扫;顶栏「刷新」=清索引+全扫。 */ #include "../reader_internal.h" #include "font_resolve.h" #include "progress.h" #include "reader_conf.h" +#include "shelf_index.h" #include "shelf_model.h" #include "meow_ui_internal.h" /* meow_gfx_fill_round(宫格卡圆角填充) */ #include @@ -77,40 +83,69 @@ static void notice_set(const char *line1, const char *line2) g_notice_until = meow_ui_time_ms() + 6000; /* 6s 后由 shelf_on_tick 清掉,不常驻压列表 */ } -/* 扫描 Books/ 并写入 g_books/g_nbooks(同时更新提示条)。首次进入书架与顶栏「刷新」才调。 */ -static void scan_books(void) +/* 扫描/索引载入共用的报告 → 提示条文案(M29 语义不变)。 + * 索引里存了 truncated/depth_skipped,载入时如实重建,避免「用索引打开」后提示失真。 */ +static void notice_from_report(const shelf_scan_report *rep) { const char *dir = READER_SD_ROOT "/Books"; g_notice[0] = 0; g_notice2[0] = 0; g_notice_ms = 0; - shelf_scan_report rep; - g_nbooks = shelf_scan_books(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep); - g_scanned = 1; - if (rep.root_missing) { + if (rep->root_missing) { char line[160]; snprintf(line, sizeof line, "未找到 %s 目录,请放入书籍(TXT)", dir); notice_set(line, NULL); - } else if (rep.truncated && rep.depth_skipped > 0) { + } else if (rep->truncated && rep->depth_skipped > 0) { /* 长句拆两行(480px 单行会被切掉) */ char l1[160], l2[160]; snprintf(l1, sizeof l1, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS); snprintf(l2, sizeof l2, "另有 %d 个文件目录超过 %d 层未显示", - rep.depth_skipped, SHELF_SCAN_MAX_DEPTH); + rep->depth_skipped, SHELF_SCAN_MAX_DEPTH); notice_set(l1, l2); - } else if (rep.truncated) { + } else if (rep->truncated) { char line[160]; snprintf(line, sizeof line, "书籍超过 %d 本,已只显示前 %d 本", SHELF_MAX_BOOKS, SHELF_MAX_BOOKS); notice_set(line, NULL); - } else if (rep.depth_skipped > 0) { + } else if (rep->depth_skipped > 0) { char line[160]; snprintf(line, sizeof line, "有 %d 个文件因目录层数超过 %d 层未显示", - rep.depth_skipped, SHELF_SCAN_MAX_DEPTH); + rep->depth_skipped, SHELF_SCAN_MAX_DEPTH); notice_set(line, NULL); } } +/* 全量扫描 Books/ 并写回索引(首次无索引 / 刷新 / 索引校验失败时走这里)。 + * 索引写盘是纯附加:open 失败(卡满/写保护/无 MeowData)→ 传 NULL 闭包,扫描照常; + * commit 失败只丢缓存。**索引问题绝不影响书架能否打开**。 */ +static void scan_books_full(void) +{ + const char *dir = READER_SD_ROOT "/Books"; + shelf_scan_report rep; + /* 扫描时把每个目录的 mtime 直接流进索引临时文件,不占额外内存、也不二次 readdir。 */ + shelf_idx_writer *w = shelf_index_writer_open(dir); + g_nbooks = shelf_scan_books_ex(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep, + w ? shelf_index_writer_dir : NULL, w); + if (w) shelf_index_writer_commit(w, g_books, g_nbooks, &rep); /* 成功/失败都接管 w */ + if (rep.root_missing) shelf_index_remove(dir); /* 卡/目录没了:旧索引作废,别留陈旧缓存 */ + g_scanned = 1; + notice_from_report(&rep); +} + +/* 首次进入书架:先试索引(命中=只 stat 目录 + 读进度,不 readdir);任何异常退回全扫。 */ +static void scan_books_first(void) +{ + const char *dir = READER_SD_ROOT "/Books"; + shelf_scan_report rep; + if (shelf_index_load(dir, g_books, SHELF_MAX_BOOKS, shelf_ts_of, &rep)) { + g_nbooks = rep.nbooks; + g_scanned = 1; + notice_from_report(&rep); + return; + } + scan_books_full(); +} + /* 排队行:folder 模式按目录分段,否则按格式(默认)。 */ static void build_rows(void) { @@ -304,12 +339,13 @@ static void shelf_on_enter(meow_view *v, void *ctx) const rdconf *c = rdconf_get(); g_layout = (c && c->shelf[0] && strcmp(c->shelf, "grid") == 0) ? 1 : 0; g_group_folder = (c && strcmp(c->shelf_group, "folder") == 0) ? 1 : 0; - /* 书架缓存:本进程只在首次进入时扫一次 Books/(从阅读页返回不再重扫)。 - * 扫描在真机是 3.1s(exFAT 434 目录 opendir/readdir),是「回书架冻结」的唯一大头。 - * 代价:新放入的书不会自动出现,需点顶栏「刷新」(强制重扫)。 + /* 书架缓存:本进程只在首次进入时取一次 Books/(从阅读页返回不再重扫)。 + * M32:首次先试索引(命中=只 stat 目录,跳过 readdir);索引不可用才全量扫描。 + * 扫描/校验在真机是 3.1s / ~0.1s 的差别(exFAT 434 目录 opendir/readdir 是扫描的唯一大头)。 + * 代价:进程内不再自动发现新书,需点顶栏「刷新」;跨进程重启则由索引 mtime 校验兜底。 * M30:刚读完那本例外——promote_just_read() 只读它一个进度文件并重排,低成本置顶。 - * 提示条(超上限截断 / 超深目录)语义不变:首次扫描时算好并保留在 g_notice*。 */ - if (!g_scanned) scan_books(); + * 提示条(超上限截断 / 超深目录)语义不变:索引里存了这两个值,载入时如实重建。 */ + if (!g_scanned) scan_books_first(); else promote_just_read(); build_rows(); const meow_theme *th = meow_theme_get(); @@ -437,10 +473,14 @@ int shelf_on_tap(int x, int y) } else if (bi == 1) { /* 分组切换:format ↔ folder */ shelf_set_group(g_group_folder ? 0 : 1); return 1; - } else if (bi == 2) { /* 刷新:强制重扫(缓存失效 + 重扫;新书/刚读完的书在此出现) */ + } else if (bi == 2) { /* 刷新:清索引 + 全量重扫(新书/改名/删书在此出现) */ g_scanned = 0; font_cache_invalidate(); /* 字体路径记忆化也一并失效(新拷入的字库在此生效) */ - scan_books(); build_rows(); + /* 先删索引再全量扫描:即使旧索引 mtime 校验碰巧通过(如文件系统时间戳精度不足), + * 刷新也一定重扫;扫描成功后写回新索引(写失败只丢缓存)。 */ + shelf_index_remove(READER_SD_ROOT "/Books"); + scan_books_full(); + build_rows(); shelf_build_content(); shelf_redraw(); return 1; diff --git a/patch_v2/reader_app/app/shelf_index.c b/patch_v2/reader_app/app/shelf_index.c new file mode 100644 index 0000000..a022e40 --- /dev/null +++ b/patch_v2/reader_app/app/shelf_index.c @@ -0,0 +1,498 @@ +/* app/shelf_index.c — 书架索引落盘/校验(实现见 app/shelf_index.h)。 + * + * 落盘格式 v1(全部小端、手工序列化,不依赖结构体布局): + * [header 24B] "MEOWIDX1" | u32 version | u32 header_size | u32 root_len | u32 rsv + * [root bytes] + * [dir 记录 ×N] u32 rel_len | u32 books_here | u64 mtime_sec | u32 mtime_nsec | u32 rsv | rel + * [book 记录 ×M] u32 rel_len | u32 size | u32 fmt | u32 rsv | rel + * [trailer 24B] u32 ndirs | u32 nbooks | u32 flags | u32 depth_skipped | u32 crc32 | u32 magic + * crc32 覆盖 [0, trailer_off)(含 header/root/全部记录),用来发现截断/坏页。 + * rel 一律相对 Books 根,路径做 ".."/绝对路径/内嵌 NUL 校验,杜绝索引坏档造成越界读。 + * + * 写入顺序:临时文件 `shelf.idx.tmp` 全量写完(flash 尽力 fsync)后 rename 发布。 + * 扫描中途被杀只会留下 tmp(下次覆盖),正式索引要么是旧的、要么是完整新的。 + */ +#include "shelf_index.h" +#include "meow_ui.h" /* meow_config_dir() */ + +#include +#include +#include +#include +#include +#include +#include + +#define IDX_HDR_SIZE 24 +#define IDX_TRL_SIZE 24 +#define IDX_TRL_MAGIC 0x314D5749u /* "IWM1" */ +#define IDX_REL_MAX 1000 /* 单条 rel 上限(真机 <100B,留足余量) */ +#define IDX_DIR_REC_FIX 24 /* dir 记录定长部分 */ +#define IDX_BOOK_REC_FIX 16 /* book 记录定长部分 */ +#define IDX_MAX_SIZE (8 * 1024 * 1024) + +static const unsigned char IDX_MAGIC[8] = { 'M','E','O','W','I','D','X','1' }; + +/* ---- 小端序列化 ---- */ +static void put32(unsigned char *p, uint32_t v) +{ + p[0] = (unsigned char)(v & 0xFF); p[1] = (unsigned char)((v >> 8) & 0xFF); + p[2] = (unsigned char)((v >> 16) & 0xFF); p[3] = (unsigned char)((v >> 24) & 0xFF); +} +static uint32_t get32(const unsigned char *p) +{ + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} +static void put64(unsigned char *p, uint64_t v) +{ + put32(p, (uint32_t)(v & 0xFFFFFFFFu)); + put32(p + 4, (uint32_t)((v >> 32) & 0xFFFFFFFFu)); +} +static uint64_t get64(const unsigned char *p) +{ + return (uint64_t)get32(p) | ((uint64_t)get32(p + 4) << 32); +} + +/* ---- CRC32(IEEE 802.3,查表;表首次使用时生成 1KB) ---- */ +static uint32_t g_crc_tbl[256]; +static int g_crc_ready; + +static void crc_table_init(void) +{ + for (uint32_t i = 0; i < 256; i++) { + uint32_t c = i; + for (int k = 0; k < 8; k++) + c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1); + g_crc_tbl[i] = c; + } + g_crc_ready = 1; +} +static uint32_t crc_step(uint32_t c, const void *buf, size_t n) +{ + if (!g_crc_ready) crc_table_init(); + const unsigned char *p = (const unsigned char *)buf; + while (n--) c = g_crc_tbl[(c ^ *p++) & 0xFF] ^ (c >> 8); + return c; +} + +/* ---- 路径推导://data → //index/shelf.idx ---- */ +static void strip_data_suffix(const char *d, char *out, int outsz) +{ + if (!d || !d[0]) { strncpy(out, "/tmp", (size_t)outsz - 1); out[outsz - 1] = 0; return; } + size_t n = strlen(d); + const char *suffix = "/data"; + size_t sl = strlen(suffix); + size_t keep = (n >= sl && strcmp(d + n - sl, suffix) == 0) ? n - sl : n; + if (keep >= (size_t)outsz) keep = (size_t)outsz - 1; + memcpy(out, d, keep); + out[keep] = 0; +} + +/* mkdir -p(逐分量,EEXIST 视为成功)。 */ +static int mkdir_p(const char *path) +{ + char tmp[520]; + if (!path || !path[0] || strlen(path) >= sizeof tmp) return -1; + memcpy(tmp, path, strlen(path) + 1); + for (char *p = tmp + 1; *p; p++) { + if (*p != '/') continue; + *p = 0; mkdir(tmp, 0755); *p = '/'; + } + mkdir(tmp, 0755); + return 0; +} + +/* 索引目录 //index(只算路径;mkdir 只在写索引时做)。 */ +static int index_dir(char *out, int outsz) +{ + const char *data = meow_config_dir(); + if (!data) return -2; + char base[400]; + strip_data_suffix(data, base, sizeof base); + if (snprintf(out, (size_t)outsz, "%s/index", base) >= outsz) return -3; + return 0; +} + +int shelf_index_path(const char *root, char *out, int outsz) +{ + (void)root; /* 索引跟 App 数据目录走,不跟 Books 路径走 */ + if (!out || outsz <= 0) return -1; + out[0] = 0; + char dir[440]; + if (index_dir(dir, sizeof dir) != 0) return -2; + if (snprintf(out, (size_t)outsz, "%s/shelf.idx", dir) >= outsz) return -5; + return 0; +} + +/* ---- rel 校验:相对路径、无 ".." 分量、无内嵌 NUL、长度受控 ---- */ +static int rel_valid(const char *rel, int len, int allow_empty) +{ + if (len < 0 || len > IDX_REL_MAX) return 0; + if (len == 0) return allow_empty; + if (rel[0] == '/') return 0; + if (memchr(rel, 0, (size_t)len) != NULL) return 0; + int comp = 0; + for (int i = 0; i <= len; i++) { + char ch = (i == len) ? '/' : rel[i]; + if (ch == '/') { + if (i - comp == 2 && rel[comp] == '.' && rel[comp + 1] == '.') return 0; + comp = i + 1; + } + } + return 1; +} + +/* 目录 rel(""/a/b)拼成绝对路径;返回 0=成功。 */ +static int join_path(const char *root, const char *rel, int rel_len, char *out, int outsz) +{ + if (rel_len == 0) { + if ((int)strlen(root) >= outsz) return -1; + memcpy(out, root, strlen(root) + 1); + return 0; + } + int n = snprintf(out, (size_t)outsz, "%s/%s", root, rel); + return (n < 0 || n >= outsz) ? -1 : 0; +} + +/* book rel → title/group/top。与 shelf_scan_books 的写法保持逐字一致 + * (path 必须是同一个绝对路径,进度键 rdprog_hash(path) 才能对上)。 */ +static void fill_from_rel(shelf_book *b, const char *rel, int rel_len) +{ + const char *base = rel; + for (int i = 0; i < rel_len; i++) if (rel[i] == '/') base = rel + i + 1; + char name[256]; + int nl = rel_len - (int)(base - rel); + if (nl >= (int)sizeof name) nl = (int)sizeof name - 1; + memcpy(name, base, (size_t)nl); + name[nl] = 0; + shelf_copy_utf8(b->title, (int)sizeof b->title, name); + char *dot = strrchr(b->title, '.'); + if (dot) *dot = 0; + + /* group = rel 去掉最后一段;top = 第一段。*/ + int glen = (int)(base - rel); + if (glen > 0) glen--; /* 去掉结尾 '/' */ + char g[IDX_REL_MAX + 1]; + if (glen < 0) glen = 0; + memcpy(g, rel, (size_t)glen); + g[glen] = 0; + shelf_copy_utf8(b->group, (int)sizeof b->group, g); + const char *sl = strchr(g, '/'); + int tlen = sl ? (int)(sl - g) : glen; + char t[IDX_REL_MAX + 1]; + memcpy(t, g, (size_t)tlen); + t[tlen] = 0; + shelf_copy_utf8(b->top, (int)sizeof b->top, t); +} + +/* ================= 载入 ================= */ + +int shelf_index_load(const char *root, shelf_book *books, int cap, + shelf_ts_fn ts_fn, shelf_scan_report *rep) +{ + if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; } + if (!root || !books || cap <= 0) return 0; + + char ipath[500]; + if (shelf_index_path(root, ipath, sizeof ipath) != 0) return 0; + /* O_NOFOLLOW/O_NONBLOCK + S_ISREG:索引是 App 自己的普通文件;卡上若有同名 FIFO/符号链接, + * 直接拒绝重扫,避免 fopen 阻塞或读到卡外内容。 */ + int fd = open(ipath, O_RDONLY | O_NONBLOCK | O_NOFOLLOW); + if (fd < 0) return 0; + struct stat fst; + if (fstat(fd, &fst) != 0 || !S_ISREG(fst.st_mode) || fst.st_size <= 0) { + close(fd); + return 0; + } + FILE *f = fdopen(fd, "rb"); + if (!f) { close(fd); return 0; } + + int ok = 0; + long fsz = (long)fst.st_size; + if (fsz < IDX_HDR_SIZE + IDX_TRL_SIZE || fsz > IDX_MAX_SIZE) goto done; + + /* trailer 先读:拿 ndirs/nbooks/crc,再从头顺序校验。 */ + unsigned char tr[IDX_TRL_SIZE]; + if (fseek(f, fsz - IDX_TRL_SIZE, SEEK_SET) != 0) goto done; + if (fread(tr, 1, sizeof tr, f) != sizeof tr) goto done; + if (get32(tr + 20) != IDX_TRL_MAGIC) goto done; + uint32_t ndirs = get32(tr + 0); + uint32_t nbooks = get32(tr + 4); + uint32_t flags = get32(tr + 8); + uint32_t depth_skipped = get32(tr + 12); + uint32_t crc_expect = get32(tr + 16); + if (ndirs < 1 || ndirs > 65535) goto done; + if (nbooks > (uint32_t)cap || nbooks > (uint32_t)SHELF_MAX_BOOKS) goto done; + if (depth_skipped > 0x7FFFFFFFu) goto done; + + long trailer_off = fsz - IDX_TRL_SIZE; + if (fseek(f, 0, SEEK_SET) != 0) goto done; + uint32_t crc = 0xFFFFFFFFu; + + /* header + root */ + unsigned char h[IDX_HDR_SIZE]; + if (fread(h, 1, sizeof h, f) != sizeof h) goto done; + crc = crc_step(crc, h, sizeof h); + if (memcmp(h, IDX_MAGIC, 8) != 0) goto done; + if (get32(h + 8) != SHELF_INDEX_VERSION) goto done; + if (get32(h + 12) != IDX_HDR_SIZE) goto done; + uint32_t root_len = get32(h + 16); + if (root_len > IDX_REL_MAX || (int)root_len != (int)strlen(root)) goto done; + char rootbuf[IDX_REL_MAX + 1]; + if (root_len > 0 && fread(rootbuf, 1, root_len, f) != root_len) goto done; + if (root_len > 0) crc = crc_step(crc, rootbuf, root_len); + rootbuf[root_len] = 0; + if (strcmp(rootbuf, root) != 0) goto done; /* 换了卡/挂载点 → 重扫 */ + + /* 目录记录:逐个 stat 比对 mtime(唯一允许的校验 IO,不做 readdir)。 */ + uint32_t sum_here = 0; + for (uint32_t i = 0; i < ndirs; i++) { + unsigned char rh[IDX_DIR_REC_FIX]; + if (fread(rh, 1, sizeof rh, f) != sizeof rh) goto done; + crc = crc_step(crc, rh, sizeof rh); + uint32_t rel_len = get32(rh + 0); + uint32_t books_here = get32(rh + 4); + uint64_t mt_sec = get64(rh + 8); + uint32_t mt_nsec = get32(rh + 16); + if (rel_len > IDX_REL_MAX || books_here > (uint32_t)cap) goto done; + char rel[IDX_REL_MAX + 1]; + if (rel_len > 0 && fread(rel, 1, rel_len, f) != rel_len) goto done; + if (rel_len > 0) crc = crc_step(crc, rel, rel_len); + rel[rel_len] = 0; + if (!rel_valid(rel, (int)rel_len, 1)) goto done; + + char path[400]; + if (join_path(root, rel, (int)rel_len, path, sizeof path) != 0) goto done; + struct stat st; + if (stat(path, &st) != 0) goto done; + if (!S_ISDIR(st.st_mode)) goto done; + if ((uint64_t)(int64_t)st.st_mtime != mt_sec) goto done; + if ((uint32_t)(long)st.st_mtim.tv_nsec != mt_nsec) goto done; + sum_here += books_here; + } + + /* 书记录 */ + for (uint32_t i = 0; i < nbooks; i++) { + unsigned char bh[IDX_BOOK_REC_FIX]; + if (fread(bh, 1, sizeof bh, f) != sizeof bh) goto done; + crc = crc_step(crc, bh, sizeof bh); + uint32_t rel_len = get32(bh + 0); + uint32_t size = get32(bh + 4); + uint32_t fmt = get32(bh + 8); + if (rel_len < 1 || rel_len > IDX_REL_MAX || fmt > 4) goto done; + char rel[IDX_REL_MAX + 1]; + if (fread(rel, 1, rel_len, f) != rel_len) goto done; + crc = crc_step(crc, rel, rel_len); + rel[rel_len] = 0; + if (!rel_valid(rel, (int)rel_len, 0)) goto done; + + shelf_book *b = &books[i]; + memset(b, 0, sizeof *b); + if (join_path(root, rel, (int)rel_len, b->path, sizeof b->path) != 0) goto done; + if ((int)strlen(b->path) != (int)(strlen(root) + 1 + rel_len)) goto done; + fill_from_rel(b, rel, (int)rel_len); + b->fmt = (int)fmt; + b->size = (long)size; + b->ts = 0; /* 进度不落盘:下面用 ts_fn 现读 */ + } + + /* 必须正好读到 trailer 起点(多/少字节都视为坏档) */ + if (ftell(f) != trailer_off) goto done; + if ((crc ^ 0xFFFFFFFFu) != crc_expect) goto done; + if (sum_here != nbooks) goto done; + + /* 校验全过:现读进度 ts(与全量扫描同一口径),重建副题并按最近阅读排序。 */ + for (uint32_t i = 0; i < nbooks; i++) { + books[i].ts = ts_fn ? ts_fn(books[i].path) : 0; + shelf_fmt_sub(&books[i]); + } + shelf_sort_recent(books, (int)nbooks); + + if (rep) { + rep->nbooks = (int)nbooks; + rep->truncated = (flags & 1u) ? 1 : 0; + rep->depth_skipped = (int)depth_skipped; + rep->root_missing = 0; + } + ok = 1; + +done: + fclose(f); + return ok; +} + +/* ================= 写入 ================= */ + +struct shelf_idx_writer { + FILE *f; + char root[400]; + char dst[500]; + char tmp[520]; + uint32_t crc; + int ndirs; + int ok; +}; + +static int wraw(shelf_idx_writer *w, const void *buf, size_t n) +{ + if (!w->ok) return -1; + if (fwrite(buf, 1, n, w->f) != n) { w->ok = 0; return -1; } + w->crc = crc_step(w->crc, buf, n); + return 0; +} + +shelf_idx_writer *shelf_index_writer_open(const char *root) +{ + if (!root) return NULL; + size_t rl = strlen(root); + if (rl == 0 || rl >= sizeof(((shelf_idx_writer *)0)->root)) return NULL; + + shelf_idx_writer *w = (shelf_idx_writer *)calloc(1, sizeof *w); + if (!w) return NULL; + memcpy(w->root, root, rl + 1); + + if (shelf_index_path(root, w->dst, sizeof w->dst) != 0) { free(w); return NULL; } + char idir[500]; + snprintf(idir, sizeof idir, "%s", w->dst); + char *slash = strrchr(idir, '/'); + if (!slash) { free(w); return NULL; } + *slash = 0; + mkdir_p(idir); /* 失败也让 fopen 去失败(只读卡 → 不写索引,书架照常) */ + if (snprintf(w->tmp, sizeof w->tmp, "%s.tmp", w->dst) >= (int)sizeof w->tmp) { free(w); return NULL; } + + w->f = NULL; + int fd = open(w->tmp, O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, 0644); + if (fd < 0) { free(w); return NULL; } + w->f = fdopen(fd, "wb"); + if (!w->f) { close(fd); unlink(w->tmp); free(w); return NULL; } + w->ok = 1; + w->crc = 0xFFFFFFFFu; + + unsigned char h[IDX_HDR_SIZE]; + memset(h, 0, sizeof h); + memcpy(h, IDX_MAGIC, 8); + put32(h + 8, SHELF_INDEX_VERSION); + put32(h + 12, IDX_HDR_SIZE); + put32(h + 16, (uint32_t)rl); + if (wraw(w, h, sizeof h) != 0 || wraw(w, root, rl) != 0) { + shelf_index_writer_abort(w); + return NULL; + } + return w; +} + +void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec, + long mtime_nsec, int books_here) +{ + shelf_idx_writer *w = (shelf_idx_writer *)ctx; + if (!w || !w->ok || !rel || books_here < 0) return; + size_t rl = strlen(rel); + if (rl > IDX_REL_MAX) { w->ok = 0; return; } + + unsigned char rh[IDX_DIR_REC_FIX]; + put32(rh + 0, (uint32_t)rl); + put32(rh + 4, (uint32_t)books_here); + put64(rh + 8, (uint64_t)mtime_sec); + put32(rh + 16, (uint32_t)mtime_nsec); + put32(rh + 20, 0); + if (wraw(w, rh, sizeof rh) != 0) return; + if (rl > 0 && wraw(w, rel, rl) != 0) return; + w->ndirs++; +} + +int shelf_index_writer_commit(shelf_idx_writer *w, const shelf_book *books, + int nbooks, const shelf_scan_report *rep) +{ + if (!w) return 0; + if (!w->ok || nbooks < 0 || (nbooks > 0 && !books) || + w->ndirs <= 0 || w->ndirs > 65535) { + shelf_index_writer_abort(w); + return 0; + } + + size_t root_len = strlen(w->root); + for (int i = 0; i < nbooks; i++) { + const shelf_book *b = &books[i]; + size_t pl = strlen(b->path); + if (pl <= root_len + 1 || strncmp(b->path, w->root, root_len) != 0 || + b->path[root_len] != '/') { + /* 路径不是扫描 root 下的相对路径(被截断/异常)→ 宁可不写索引 */ + shelf_index_writer_abort(w); + return 0; + } + const char *rel = b->path + root_len + 1; + size_t rl = strlen(rel); + if (rl < 1 || rl > IDX_REL_MAX || b->fmt < 0 || b->fmt > 4 || + b->size < 0 || (uint64_t)b->size > 0xFFFFFFFFu) { + shelf_index_writer_abort(w); + return 0; + } + unsigned char bh[IDX_BOOK_REC_FIX]; + put32(bh + 0, (uint32_t)rl); + put32(bh + 4, (uint32_t)b->size); + put32(bh + 8, (uint32_t)b->fmt); + put32(bh + 12, 0); + if (wraw(w, bh, sizeof bh) != 0 || wraw(w, rel, rl) != 0) { + shelf_index_writer_abort(w); + return 0; + } + } + + uint32_t flags = 0; + int depth = 0; + if (rep) { + if (rep->truncated) flags |= 1u; + if (rep->depth_skipped > 0) { flags |= 2u; depth = rep->depth_skipped; } + } + unsigned char tr[IDX_TRL_SIZE]; + put32(tr + 0, (uint32_t)w->ndirs); + put32(tr + 4, (uint32_t)nbooks); + put32(tr + 8, flags); + put32(tr + 12, (uint32_t)depth); + put32(tr + 16, w->crc ^ 0xFFFFFFFFu); + put32(tr + 20, IDX_TRL_MAGIC); + if (wraw(w, tr, sizeof tr) != 0) { + shelf_index_writer_abort(w); + return 0; + } + + /* 刷盘后再 rename:断电最多丢 tmp,正式索引保持旧版本(仍受 mtime 校验保护)。 + * fsync 失败不算致命(FUSE/部分卡返回 EINVAL):rename 后若内容不完整,CRC 会拒绝。 */ + if (fflush(w->f) != 0) { + shelf_index_writer_abort(w); + return 0; + } + (void)fsync(fileno(w->f)); + if (fclose(w->f) != 0) { + w->f = NULL; + unlink(w->tmp); + free(w); + return 0; + } + w->f = NULL; + if (rename(w->tmp, w->dst) != 0) { + /* exFAT 某些实现不支持「rename 覆盖已存在文件」:先删旧再改名。 */ + unlink(w->dst); + if (rename(w->tmp, w->dst) != 0) { + unlink(w->tmp); + free(w); + return 0; + } + } + free(w); + return 1; +} + +void shelf_index_writer_abort(shelf_idx_writer *w) +{ + if (!w) return; + if (w->f) fclose(w->f); + if (w->tmp[0]) unlink(w->tmp); + free(w); +} + +void shelf_index_remove(const char *root) +{ + char ipath[500]; + if (shelf_index_path(root, ipath, sizeof ipath) == 0) unlink(ipath); +} diff --git a/patch_v2/reader_app/app/shelf_index.h b/patch_v2/reader_app/app/shelf_index.h new file mode 100644 index 0000000..e55a4c5 --- /dev/null +++ b/patch_v2/reader_app/app/shelf_index.h @@ -0,0 +1,55 @@ +/* app/shelf_index.h — 书架索引落盘(M32):把一次全量扫描的结果 + 每个目录的 mtime + * 存到 `MeowData/os.meow.reader/index/shelf.idx`,冷启动时用「逐个 stat 目录 mtime」校验, + * 命中就完全跳过 readdir(真机全量扫描 3.0s 的大头是 434 次 opendir+readdir)。 + * + * 安全优先(fail-open):版本不符 / 文件缺失 / 截断 / CRC 错 / 任一目录 mtime 变化 / + * 任何 IO 错误 → shelf_index_load() 返回 0,调用方照常全量重扫。**绝不**用陈旧索引 + * 冒充真实书库,也绝不因为索引写失败而让书架打不开。 + * + * 索引里**不存阅读进度 ts**:ts 每次由调用方的 ts_fn(生产=rdprog_load)现读, + * 否则「刚读完回书架」会显示旧的置顶顺序。索引只存与进度无关的扫描事实。 + * + * 目录 mtime 失效语义(存「扫描过的每个目录」的 mtime,非只存 level-1): + * - 任意目录里新增/删除/改名文件 → 该目录 mtime 变 → 校验失败 → 重扫(含 level-3 深处); + * - 一级目录改名 → 父目录 mtime 变 + 旧目录 stat 失败 → 重扫; + * - 超深(> SHELF_SCAN_MAX_DEPTH)未扫描的子树不在索引里,其内部变化不会自动失效 + * (那些书本来就不显示),需点「刷新」更新 depth_skipped 计数。 + * mtime 精度受文件系统限制(exFAT 约 10ms / FAT32 2s):与上次扫描落在同一时间刻度的 + * 变更可能漏判,点一次「刷新」必可纠正(刷新=清索引+全量重扫)。 + */ +#ifndef SHELF_INDEX_H +#define SHELF_INDEX_H + +#include "shelf_model.h" + +/* 落盘格式版本:**扫描语义变了必须 +1**(新增格式扩展名/改深度/改分组成因等), + * 版本不符时旧索引直接作废重扫。 */ +#define SHELF_INDEX_VERSION 1 + +/* 载入并校验索引。成功返回 1(books/rep 填好、已按 ts 排序,等价于一次全量扫描); + * 任何异常返回 0(fail-open,books 内容不可用,调用方必须全量重扫)。 + * 成功路径**不做 opendir/readdir**:只 open/read 索引 + 逐目录 stat + 每本书 ts_fn。 */ +int shelf_index_load(const char *root, shelf_book *books, int cap, + shelf_ts_fn ts_fn, shelf_scan_report *rep); + +/* 增量写索引(扫描时逐目录上报 → 扫描结束提交)。 + * open 失败返回 NULL(卡满/写保护/无 MeowData):调用方传 NULL 即可,扫描照常。 + * dir 回调签名与 shelf_dir_fn 一致,可直接传给 shelf_scan_books_ex。 + * commit 成功返回 1 并原子发布(临时文件 + rename);失败返回 0 且清理临时文件。 + * commit 总是接管 writer(成功/失败都会释放),之后不得再用。 */ +typedef struct shelf_idx_writer shelf_idx_writer; + +shelf_idx_writer *shelf_index_writer_open(const char *root); +void shelf_index_writer_dir(void *ctx, const char *rel, int64_t mtime_sec, + long mtime_nsec, int books_here); +int shelf_index_writer_commit(shelf_idx_writer *w, const shelf_book *books, + int nbooks, const shelf_scan_report *rep); +void shelf_index_writer_abort(shelf_idx_writer *w); + +/* 删除索引(顶栏「刷新」= 清索引 + 全量重扫;root 只用于定位,不存在也静默)。 */ +void shelf_index_remove(const char *root); + +/* 索引文件路径(测试/诊断用)。0=成功;负值=无数据目录/路径超长。 */ +int shelf_index_path(const char *root, char *out, int outsz); + +#endif /* SHELF_INDEX_H */ diff --git a/patch_v2/reader_app/app/shelf_model.c b/patch_v2/reader_app/app/shelf_model.c index ea126de..64f4325 100644 --- a/patch_v2/reader_app/app/shelf_model.c +++ b/patch_v2/reader_app/app/shelf_model.c @@ -100,6 +100,8 @@ typedef struct scan_ctx { int truncated; int depth_skipped; shelf_ts_fn ts_fn; + shelf_dir_fn dir_fn; /* M32:目录 mtime 采集(NULL=不采集) */ + void *dir_ctx; } scan_ctx; /* 统计 dir 子树里还能识别的书籍文件数(只数不收集)。自身留 8 层硬上限, @@ -125,13 +127,19 @@ static int count_books_under(const char *dir, int depth) } /* 递归收集。rel = 相对 root 的完整目录路径(root 自身为 ""), - * top = 第一级目录名(root 自身/根下文件为 "")。 */ -static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, const char *top) + * top = 第一级目录名(root 自身/根下文件为 "")。 + * dst = 本目录的 stat 结果(调用方已 stat;NULL=没有 mtime 可上报)。 + * M32:扫描完一个目录后用 dir_fn 上报 (rel, mtime, 本目录收录书数),供索引做失效校验。 + * books_here 只数**实际写进 books[] 的**书(被 cap 丢掉的不计),保证索引里 + * sum(books_here) == nbooks,载入时可用它做一致性校验。 */ +static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, + const char *top, const struct stat *dst) { if (depth > SHELF_SCAN_MAX_DEPTH) return; /* 防深嵌套/循环 */ if (c->n >= c->cap) return; /* 已满:不再进新目录 */ DIR *dp = opendir(dir); if (!dp) return; + int books_here = 0; struct dirent *de; while ((de = readdir(dp))) { if (de->d_name[0] == '.') continue; @@ -154,7 +162,7 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, c char subrel[SHELF_GROUP_MAX * 16]; snprintf(subrel, sizeof subrel, "%s%s%s", rel, rel[0] ? "/" : "", de->d_name); /* 第一级目录名 = top;更深层沿用。de->d_name 在递归返回前有效。 */ - scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top); + scan_dir(c, path, depth + 1, subrel, depth == 0 ? de->d_name : top, &st); continue; } int f = shelf_fmt_from_ext(de->d_name); @@ -172,12 +180,17 @@ static void scan_dir(scan_ctx *c, const char *dir, int depth, const char *rel, c b->ts = c->ts_fn ? c->ts_fn(b->path) : 0; shelf_fmt_sub(b); /* 副题:格式 · 大小 [· 读](+ folder 子路径) */ c->n++; + books_here++; } closedir(dp); + if (c->dir_fn && dst) + c->dir_fn(c->dir_ctx, rel, (int64_t)dst->st_mtime, + (long)dst->st_mtim.tv_nsec, books_here); } -int shelf_scan_books(const char *root, shelf_book *books, int cap, - shelf_ts_fn ts_fn, shelf_scan_report *rep) +int shelf_scan_books_ex(const char *root, shelf_book *books, int cap, + shelf_ts_fn ts_fn, shelf_scan_report *rep, + shelf_dir_fn dir_fn, void *dir_ctx) { if (rep) { rep->nbooks = 0; rep->truncated = 0; rep->depth_skipped = 0; rep->root_missing = 0; } if (!root || !books || cap <= 0) { @@ -191,9 +204,14 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap, } closedir(dp); + /* root 自身的 mtime 也要上报(新增/删除 Books/ 下的一级目录会改它)。 */ + struct stat rst; + int have_rst = (stat(root, &rst) == 0); + scan_ctx c; - c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0; c.ts_fn = ts_fn; - scan_dir(&c, root, 0, "", ""); + c.books = books; c.cap = cap; c.n = 0; c.truncated = 0; c.depth_skipped = 0; + c.ts_fn = ts_fn; c.dir_fn = dir_fn; c.dir_ctx = dir_ctx; + scan_dir(&c, root, 0, "", "", have_rst ? &rst : NULL); shelf_sort_recent(books, c.n); if (rep) { rep->nbooks = c.n; @@ -203,6 +221,12 @@ int shelf_scan_books(const char *root, shelf_book *books, int cap, return c.n; } +int shelf_scan_books(const char *root, shelf_book *books, int cap, + shelf_ts_fn ts_fn, shelf_scan_report *rep) +{ + return shelf_scan_books_ex(root, books, cap, ts_fn, rep, NULL, NULL); +} + /* ---- 行序列 ---- */ /* 分组键 = top(Books 第一级目录名)。长度截到第一个 '/',排序时忽略大小写。 */ diff --git a/patch_v2/reader_app/app/shelf_model.h b/patch_v2/reader_app/app/shelf_model.h index 6dacd59..3e44ead 100644 --- a/patch_v2/reader_app/app/shelf_model.h +++ b/patch_v2/reader_app/app/shelf_model.h @@ -51,6 +51,13 @@ typedef struct shelf_row { /* 进度时间戳查询(生产=rdprog_load;测试/基线=NULL,全部 ts=0)。 */ typedef uint64_t (*shelf_ts_fn)(const char *path); +/* M32:扫描时逐目录回调(书架索引的失效校验数据源)。 + * rel = 相对 root 的目录路径(root 自身为 ""),books_here = 该目录里**实际收录**的 + * 书籍文件数,mtime = 该目录的 stat 结果(秒 + 纳秒)。 + * 回调**不能中止扫描**(返回 void):索引写盘失败只应放弃索引,书架必须照常出书。 */ +typedef void (*shelf_dir_fn)(void *ctx, const char *rel, int64_t mtime_sec, + long mtime_nsec, int books_here); + /* 最近阅读排序:ts 降序,同 ts 按书名(不区分大小写)。扫描后与「刚读完置顶」共用。 */ void shelf_sort_recent(shelf_book *books, int nbooks); @@ -63,7 +70,13 @@ typedef struct shelf_scan_report { /* 递归扫描 root(目录深度 ≤ SHELF_SCAN_MAX_DEPTH,超限文件计数但不出书), * 按 ts 降序/书名升序排序后写 books(容量 cap)。 - * 返回写入本数。空目录不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 */ + * 返回写入本数。空目录不产生任何记录(段头由 build_rows 按书生成,天然无空段头)。 + * dir_fn 非 NULL 时,每进入并成功扫描一个目录回调一次(见 shelf_dir_fn)。 */ +int shelf_scan_books_ex(const char *root, shelf_book *books, int cap, + shelf_ts_fn ts_fn, shelf_scan_report *rep, + shelf_dir_fn dir_fn, void *dir_ctx); + +/* 同 shelf_scan_books_ex(不采集目录 mtime),供旧调用方/基准复用。 */ int shelf_scan_books(const char *root, shelf_book *books, int cap, shelf_ts_fn ts_fn, shelf_scan_report *rep); diff --git a/patch_v2/reader_app/build_reader.sh b/patch_v2/reader_app/build_reader.sh index 5f8cdba..f21de04 100755 --- a/patch_v2/reader_app/build_reader.sh +++ b/patch_v2/reader_app/build_reader.sh @@ -7,6 +7,8 @@ # reader mipsel 静态 ELF(链 meow_ui.a + freetype + miniz + libmobi + zlib) # test/test_reader_core_host 宿主 core 单测(enc/layout/txt) # test/shelf_group_smoke_host 宿主书架分组单测(M27/M31 副题) +# test/shelf_index_smoke_host 宿主书架索引单测(M32:命中/失效/fail-open/ts 不缓存) +# test/shelf_index_bench_host 宿主索引耗时基准(M32,只编不跑;bench/indexonly) # test/font_resolve_smoke_host 宿主字体路径记忆化单测(M31) # test/font_resolve_bench_host 宿主字体 walk/命中耗时对比(M31,只编不跑) # test/loaders_smoke_host 宿主四格式 loader 单测 @@ -119,6 +121,11 @@ echo "==> host shelf group test (M27)" $HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_group_smoke.c app/shelf_model.c app/progress.c app/reader_conf.c -o test/shelf_group_smoke_host ./test/shelf_group_smoke_host +echo "==> host shelf index test (M32)" +$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_index_smoke.c app/shelf_model.c app/shelf_index.c app/progress.c -o test/shelf_index_smoke_host +./test/shelf_index_smoke_host +$HOST_CC -O2 -Wall -Wextra -Iapp -I../sdk test/shelf_index_bench.c app/shelf_model.c app/shelf_index.c -o test/shelf_index_bench_host + echo "==> host font resolve test (M31)" $HOST_CC -O2 -Wall -Wextra -Iapp test/font_resolve_smoke.c app/font_resolve.c -o test/font_resolve_smoke_host ./test/font_resolve_smoke_host @@ -169,7 +176,7 @@ run_qemu 150 "$QEMU_BIN" ./test/formats_smoke_mipsel "${FONT:-$ROOT/font/src/MiS echo "==> reader binary" CORE_L="core/rdenc.c core/rdlayout.c core/rdtxt.c core/seg.c core/rdchapter.c core/rdxml.c core/rdhtml.c core/rdsb.c core/rdzip.c core/rdsrc.c core/rdepub.c core/rdfb2.c core/rdmobi.c core/rdcbz.c" ENGINE_L="engine/rdft.c" -APP_L="app.c app/shelf.c app/shelf_model.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" +APP_L="app.c app/shelf.c app/shelf_model.c app/shelf_index.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" $CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \ $CORE_L $APP_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \ ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o reader @@ -181,7 +188,7 @@ echo " 运行(qemu): qemu-mipsel ./reader" # ---------- 8) 真机耗时基准(不进发行包:与 reader 同源,只把 app.c 的 main 换成 bench main) ---------- # 用法(设备):MEOW_FONT_* 等 env 与 App 一致时执行 ./shelf_bench_ondev_mipsel [书...] echo "==> ondev shelf bench (not shipped)" -APP_BENCH_L="app/shelf.c app/shelf_model.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" +APP_BENCH_L="app/shelf.c app/shelf_model.c app/shelf_index.c app/font_resolve.c app/reader.c app/book.c app/progress.c app/settings.c app/reader_conf.c app/reader_ipc.c" $CC -Os -static -Wall -Wextra -I../sdk -I../meow_launcherd -I. -Icore -Iapp -Iengine $FT_INCS $COREINC \ test/shelf_bench_ondev.c $CORE_L $APP_BENCH_L $ENGINE_L "$OBJ"/mz_*.o "$OBJ"/lm_*.o "$OBJ"/rdimg.o \ ../sdk/meow_ui.a "$SYSROOT/lib/libz.a" "$FT_BUILD/libfreetype.a" -lpthread -o test/shelf_bench_ondev_mipsel diff --git a/patch_v2/reader_app/test/shelf_bench_ondev.c b/patch_v2/reader_app/test/shelf_bench_ondev.c index 7302de2..055b904 100644 --- a/patch_v2/reader_app/test/shelf_bench_ondev.c +++ b/patch_v2/reader_app/test/shelf_bench_ondev.c @@ -15,13 +15,44 @@ */ #include "reader_internal.h" #include "app/shelf_model.h" +#include "app/shelf_index.h" #include "meow_ui_internal.h" #include #include #include +#include static uint64_t now_ms(void) { return meow_ui_time_ms(); } +/* M32:索引命中/未命中的耗时(不含 UI)。先在本次运行开头测: + * - index_load:读到上次运行写下的索引(首次运行 miss); + * - scan_save :全量扫描 + 落盘(生产「无索引首次进入」)。 + * 之后 shelf_on_enter 会走生产路径:有索引=命中(默认),MEOW_BENCH_IDX=miss 时先删索引。 */ +static void bench_index(void) +{ + static shelf_book books[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + uint64_t t0 = now_ms(); + int hit = shelf_index_load(READER_SD_ROOT "/Books", books, SHELF_MAX_BOOKS, NULL, &rep); + uint64_t t1 = now_ms(); + printf("BENCH index_load_ms %llu (hit=%d n=%d trunc=%d skip=%d)\n", + (unsigned long long)(t1 - t0), hit, rep.nbooks, rep.truncated, rep.depth_skipped); + + t0 = now_ms(); + shelf_idx_writer *w = shelf_index_writer_open(READER_SD_ROOT "/Books"); + int n = shelf_scan_books_ex(READER_SD_ROOT "/Books", books, SHELF_MAX_BOOKS, NULL, &rep, + w ? shelf_index_writer_dir : NULL, w); + int ok = w ? shelf_index_writer_commit(w, books, n, &rep) : 0; + t1 = now_ms(); + char ip[500]; + struct stat st; + long long isz = (shelf_index_path(READER_SD_ROOT "/Books", ip, sizeof ip) == 0 && + stat(ip, &st) == 0) ? (long long)st.st_size : -1; + printf("BENCH scan_save_ms %llu (n=%d commit=%d index_bytes=%lld)\n", + (unsigned long long)(t1 - t0), n, ok, isz); + fflush(stdout); +} + static void bench_scan(void) { static shelf_book books[SHELF_MAX_BOOKS]; @@ -40,8 +71,17 @@ int main(int argc, char **argv) if (meow_ui_init(&o) != 0) { printf("BENCH init_fail\n"); return 1; } meow_theme_add_listener(NULL); + bench_index(); /* M32:索引命中/未命中 + 扫描落盘(不含 UI) */ bench_scan(); /* 纯扫描基线(不建 UI) */ + /* 首次进书架:默认走索引命中;MEOW_BENCH_IDX=miss 先删索引 → 走全量扫描(对比用)。 */ + const char *eidx = getenv("MEOW_BENCH_IDX"); + if (eidx && strcmp(eidx, "miss") == 0) { + shelf_index_remove(READER_SD_ROOT "/Books"); + printf("BENCH index_removed (miss path)\n"); + fflush(stdout); + } + shelf_view_create(); reader_view_create(); settings_view_create(); diff --git a/patch_v2/reader_app/test/shelf_index_bench.c b/patch_v2/reader_app/test/shelf_index_bench.c new file mode 100644 index 0000000..a8a4c6c --- /dev/null +++ b/patch_v2/reader_app/test/shelf_index_bench.c @@ -0,0 +1,254 @@ +/* test/shelf_index_bench.c — M32 索引方案可行性计时(宿主,纯 C + clock_gettime)。 + * + * 用法: + * ./shelf_index_bench_host [bench|indexonly] [root] + * bench —— 建虚拟书库(默认 452 目录 / 688 本,贴合真机 434/595 量级)并计时 + * indexonly —— 只载入已有索引一次(供 `strace -e getdents64` 断言「载入不 readdir」) + * + * 为什么宿主数字不能直接当真机用:宿主是 ext4/f2fs 热缓存(每次 stat ~1µs),真机 TF 卡是 + * exFAT(M28 实测 1079 次 stat = 160–170ms,约 150µs/次)。本工具用来量**调用次数与代码路径** + * 的相对成本;绝对毫秒按 M28 的每次 stat/条目成本外推(见报文)。 + * + * 计时口径: + * - stat_all :先 opendir 走一遍把目录列表收好(不计时),再单独循环 stat(计时的只有 stat); + * - full_scan :shelf_scan_books_ex(生产全量扫描形态,ts_fn=NULL); + * - scan+save :全量扫描 + 索引落盘(生产「无索引首次进入」路径); + * - index_load :shelf_index_load 命中(生产「有索引首次进入」路径,ts_fn=NULL 以隔离进度读); + * - index_read :单次 open+fstat+read 整个索引文件(校验之前的纯 IO 成本)。 + */ +#include "shelf_model.h" +#include "shelf_index.h" + +#include +#include +#include +#include +#include +#include +#include + +static char g_cfgdir[500]; +const char *meow_config_dir(void) { return g_cfgdir; } + +static double now_ms(void) +{ + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6; +} + +static void mkdirs(const char *path) +{ + char t[600]; + snprintf(t, sizeof t, "%s", path); + for (char *p = t + 1; *p; p++) { + if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; } + } + mkdir(t, 0755); +} + +static void put_file(const char *path, long size) +{ + FILE *f = fopen(path, "wb"); + if (!f) return; + fseek(f, size > 0 ? size - 1 : 0, SEEK_SET); + fputc('x', f); + fclose(f); +} + +/* 虚拟书库:精确复刻真机量级 —— 434 个目录 / 595 本书 / 51 个 .rar(不支持格式)。 + * 根 1 + 一级 10 + 「400+本高质量完本合集」下 40 分类 + 382 作品目录 + 轻小说/子目录 1 = 434; + * 书 = 382 作品 × 1 + 其它 9 个一级目录 × 20 + 根 3 + 子目录 30 = 595。 */ +static int make_library(const char *root) +{ + char p[600]; + DIR *d = opendir(root); + if (d) { closedir(d); return 0; } /* 已存在:复用 */ + mkdirs(root); + const char *l1[] = { "400+本高质量完本合集", "轻小说", "玄幻", "都市", "科幻", + "历史", "武侠", "游戏", "悬疑", "同人科幻" }; + for (size_t i = 0; i < sizeof l1 / sizeof l1[0]; i++) { + snprintf(p, sizeof p, "%s/%s", root, l1[i]); mkdirs(p); + } + int works = 0, rar = 0; + /* 40 个分类目录,382 个作品目录(前 22 个分类各 10 个,其余各 9 个): + * 1 根 + 10 一级 + 40 分类 + 382 作品 + 1 子目录 = 434 个目录。 */ + for (int c = 1; c <= 40; c++) { + snprintf(p, sizeof p, "%s/400+本高质量完本合集/%02d、分类%d", root, c, c); mkdirs(p); + int nw = (c <= 22) ? 10 : 9; + for (int w = 1; w <= nw; w++, works++) { + char wd[600]; + snprintf(wd, sizeof wd, "%s/400+本高质量完本合集/%02d、分类%d/%02d、作品%d", + root, c, c, w, w); + mkdirs(wd); + snprintf(p, sizeof p, "%s/第1卷.txt", wd); + put_file(p, 1024 + (long)((works * 7919) % 200000)); + if ((works % 7) == 0 && rar < 51) { snprintf(p, sizeof p, "%s/合集.rar", wd); put_file(p, 1000); rar++; } + } + } + for (size_t i = 1; i < sizeof l1 / sizeof l1[0]; i++) { + for (int b = 1; b <= 20; b++) { + snprintf(p, sizeof p, "%s/%s/书%d.txt", root, l1[i], b); + put_file(p, 4096); + } + } + for (int b = 1; b <= 3; b++) { + snprintf(p, sizeof p, "%s/根下%d.txt", root, b); + put_file(p, 2048); + } + snprintf(p, sizeof p, "%s/轻小说/子目录", root); mkdirs(p); + for (int b = 1; b <= 30; b++) { + snprintf(p, sizeof p, "%s/轻小说/子目录/深%d.txt", root, b); + put_file(p, 4096); + } + /* 补齐 51 个 rar(若作品目录里不够,放到一级目录) */ + while (rar < 51) { + snprintf(p, sizeof p, "%s/玄幻/压缩%d.rar", root, rar); + put_file(p, 100); + rar++; + } + return 1; +} + +/* 收集目录列表(不计时;用于单独量 stat 成本)。 */ +static int collect_dirs(const char *dir, char paths[][512], int max, int n, int depth) +{ + if (depth > 8 || n >= max) return n; + DIR *d = opendir(dir); + if (!d) return n; + struct dirent *e; + while ((e = readdir(d))) { + if (e->d_name[0] == '.') continue; + char path[512]; + if (snprintf(path, sizeof path, "%s/%s", dir, e->d_name) >= (int)sizeof path) continue; + struct stat st; + if (stat(path, &st) != 0) continue; + if (S_ISDIR(st.st_mode) && n < max) { + snprintf(paths[n], 512, "%s", path); + n = collect_dirs(path, paths, max, n + 1, depth + 1); + } + } + closedir(d); + return n; +} + +static int count_files(const char *dir, int depth) +{ + if (depth > 8) return 0; + DIR *d = opendir(dir); + if (!d) return 0; + int n = 0; + struct dirent *e; + while ((e = readdir(d))) { + if (e->d_name[0] == '.') continue; + char path[512]; + snprintf(path, sizeof path, "%s/%s", dir, e->d_name); + struct stat st; + if (stat(path, &st) != 0) continue; + if (S_ISDIR(st.st_mode)) n += count_files(path, depth + 1); + else n++; + } + closedir(d); + return n; +} + +int main(int argc, char **argv) +{ + const char *mode = (argc > 1) ? argv[1] : "bench"; + const char *tmp = getenv("TMPDIR"); + if (!tmp || !tmp[0]) tmp = "/tmp"; + char base[500], root[520]; + if (argc > 2) snprintf(base, sizeof base, "%s", argv[2]); + else snprintf(base, sizeof base, "%s/m32bench", tmp); + snprintf(root, sizeof root, "%s/Books", base); + snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", base); + mkdirs(g_cfgdir); + + static shelf_book books[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + + if (strcmp(mode, "indexonly") == 0) { + int hit = shelf_index_load(root, books, SHELF_MAX_BOOKS, NULL, &rep); + printf("indexonly hit=%d nbooks=%d\n", hit, rep.nbooks); + return hit ? 0 : 1; + } + + int created = make_library(root); + static char dirs[4096][512]; + snprintf(dirs[0], 512, "%s", root); + int nd = 1 + collect_dirs(root, dirs, 4096, 0, 0); + int nfiles = count_files(root, 0); + printf("library: %s (%s) dirs=%d files=%d\n", root, created ? "created" : "reused", nd, nfiles); + + /* ① 目录逐个 stat(校验索引的唯一 IO) */ + struct stat st; + int reps = 20; + double t0 = now_ms(); + for (int r = 0; r < reps; r++) + for (int i = 0; i < nd; i++) stat(dirs[i], &st); + double t1 = now_ms(); + printf("stat_all_dirs: %d x%d = %.2f ms total, %.3f ms/pass, %.2f us/stat\n", + nd, reps, t1 - t0, (t1 - t0) / reps, (t1 - t0) * 1000.0 / (reps * nd)); + + /* ② 全量扫描(无 ts) */ + double best_scan = 1e9, best_save = 1e9; + int n = 0; + for (int r = 0; r < 3; r++) { + double a = now_ms(); + n = shelf_scan_books_ex(root, books, SHELF_MAX_BOOKS, NULL, &rep, NULL, NULL); + double b = now_ms(); + if (b - a < best_scan) best_scan = b - a; + } + printf("full_scan: books=%d truncated=%d depth_skipped=%d best=%.2f ms\n", + n, rep.truncated, rep.depth_skipped, best_scan); + int scan_trunc = rep.truncated, scan_skip = rep.depth_skipped; + + /* ③ 全量扫描 + 写索引 */ + for (int r = 0; r < 3; r++) { + double a = now_ms(); + shelf_idx_writer *w = shelf_index_writer_open(root); + int n2 = shelf_scan_books_ex(root, books, SHELF_MAX_BOOKS, NULL, &rep, + w ? shelf_index_writer_dir : NULL, w); + int ok = w ? shelf_index_writer_commit(w, books, n2, &rep) : 0; + double b = now_ms(); + if (b - a < best_save) best_save = b - a; + if (r == 0) printf("scan+save: scan_books=%d index_commit=%d\n", n2, ok); + } + printf("scan+save: best=%.2f ms\n", best_save); + + /* ④ 索引命中(不含 ts 读取) */ + double best_load = 1e9; + int hit = 0, ln = 0; + for (int r = 0; r < 5; r++) { + double a = now_ms(); + hit = shelf_index_load(root, books, SHELF_MAX_BOOKS, NULL, &rep); + double b = now_ms(); + ln = rep.nbooks; + if (b - a < best_load) best_load = b - a; + } + printf("index_load: hit=%d nbooks=%d best=%.2f ms\n", hit, ln, best_load); + + /* ⑤ 索引文件大小 + 单次顺序读成本 */ + char ip[600]; + if (shelf_index_path(root, ip, sizeof ip) == 0 && stat(ip, &st) == 0) { + char *buf = (char *)malloc((size_t)st.st_size); + double a = now_ms(); + for (int r = 0; r < 20 && buf; r++) { + FILE *f = fopen(ip, "rb"); + if (f) { if (fread(buf, 1, (size_t)st.st_size, f) != (size_t)st.st_size) { /* 短读 */ } fclose(f); } + } + double b = now_ms(); + printf("index_file: %lld bytes, open+read %.3f ms/pass\n", + (long long)st.st_size, buf ? (b - a) / 20 : 0.0); + free(buf); + } + + /* ⑥ 生产路径估算:索引命中 + 595 次 rdprog_load(M28 实测 40–50ms,未在此重复测) */ + printf("NOTE 生产路径 = index_load + rdprog_load×nbooks(M28: 40–50ms)+ build_rows(<20ms)\n"); + + /* ⑦ 模拟真机书库的正确性断言:595 本全可见、无截断、无超深跳过,索引与扫描同数。 */ + int pass = (n == 595 && ln == 595 && hit == 1 && scan_trunc == 0 && scan_skip == 0); + printf("CHECK library_sim scan_books=%d load_books=%d hit=%d truncated=%d depth_skipped=%d -> %s\n", + n, ln, hit, scan_trunc, scan_skip, pass ? "PASS" : "FAIL"); + return pass ? 0 : 1; +} diff --git a/patch_v2/reader_app/test/shelf_index_smoke.c b/patch_v2/reader_app/test/shelf_index_smoke.c new file mode 100644 index 0000000..b4da798 --- /dev/null +++ b/patch_v2/reader_app/test/shelf_index_smoke.c @@ -0,0 +1,426 @@ +/* test/shelf_index_smoke.c — M32 书架索引宿主单测(纯 C,无设备/SDK 依赖)。 + * + * 覆盖: + * I1 全量扫描 → 写索引 → 载入:书数/书名/分组/path/fmt/size/ts 全部与扫描一致 + * I2 载入不缓存阅读进度:同一索引换 ts_fn 再载入,ts 立刻反映新值(含真实 rdprog 文件) + * I3 四种书库变更都必须失效(fail-open 重扫): + * level-2 新增 / level-3 深处新增 / level-1 目录改名 / 删书 + * I4 坏档 fail-open:截断 / 中间字节翻转 / 垃圾内容 / 文件缺失 / 索引路径是目录 / + * 版本不符 / Books 根路径不符 —— 一律返回 0,不崩 + * I5 truncated / depth_skipped 如实重建(索引里存了这两个值) + * I6 写盘失败不影响书架:索引目录不可写时 writer_open 返回 NULL、扫描照常出书; + * commit 失败不留下半截索引 + * I7 空 Books / Books 不存在:空索引可载入;root_missing 不写索引 + * I8 载入路径不做 readdir(由 build 验证步骤用 strace 另行断言) + */ +#include "shelf_model.h" +#include "shelf_index.h" +#include "progress.h" + +#include +#include +#include +#include +#include +#include + +static int g_pass = 0, g_fail = 0; +#define CHECK(cond, msg) do { \ + if (cond) g_pass++; \ + else { g_fail++; printf("FAIL %s:%d %s\n", __FILE__, __LINE__, msg); } \ +} while (0) + +static char g_cfgdir[400]; +const char *meow_config_dir(void) { return g_cfgdir; } + +static void mkdirs(const char *path) +{ + char t[400]; + snprintf(t, sizeof t, "%s", path); + for (char *p = t + 1; *p; p++) { + if (*p == '/') { *p = 0; mkdir(t, 0755); *p = '/'; } + } + mkdir(t, 0755); +} + +static void write_file(const char *path, const char *body) +{ + FILE *f = fopen(path, "wb"); + if (!f) { printf("无法创建 %s\n", path); exit(2); } + fputs(body, f); + fclose(f); +} + +/* 进度按文件名给(I2:换 ts_fn 必须立即生效,证明索引没缓存 ts)。 */ +static uint64_t g_ts_boost; +static uint64_t ts_of(const char *path) +{ + const char *b = strrchr(path, '/'); + b = b ? b + 1 : path; + if (strcmp(b, "1.txt") == 0) return 100 + g_ts_boost; + if (strcmp(b, "2.epub") == 0) return 50; + return 0; +} + +/* 真实进度文件口径(I2 第二段)。 */ +static uint64_t prog_ts_of(const char *path) +{ + rdprog p; + return (rdprog_load(path, &p) == 1) ? p.ts : 0; +} + +static const shelf_book *find_path(const shelf_book *books, int n, const char *path) +{ + for (int i = 0; i < n; i++) if (strcmp(books[i].path, path) == 0) return &books[i]; + return NULL; +} + +static const shelf_book *find_title(const shelf_book *books, int n, const char *title) +{ + for (int i = 0; i < n; i++) if (strcmp(books[i].title, title) == 0) return &books[i]; + return NULL; +} + +static int idx_path(const char *root, char *out, int outsz) +{ + return shelf_index_path(root, out, outsz); +} + +static int scan_and_save(const char *root, shelf_book *books, int cap, shelf_scan_report *rep) +{ + shelf_idx_writer *w = shelf_index_writer_open(root); + CHECK(w != NULL, "writer_open 成功"); + int n = shelf_scan_books_ex(root, books, cap, ts_of, rep, + w ? shelf_index_writer_dir : NULL, w); + if (w) CHECK(shelf_index_writer_commit(w, books, n, rep) == 1, "writer_commit 成功"); + return n; +} + +/* 建一棵贴合真机布局的树(含 level-3 深处、超深目录、不支持格式、空目录)。 */ +static void make_tree(const char *root) +{ + char p[400]; + mkdirs(root); + snprintf(p, sizeof p, "%s/轻小说", root); mkdirs(p); + snprintf(p, sizeof p, "%s/玄幻", root); mkdirs(p); + snprintf(p, sizeof p, "%s/B/sub", root); mkdirs(p); + snprintf(p, sizeof p, "%s/D/E/F", root); mkdirs(p); + snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M", root); mkdirs(p); + snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破", root); mkdirs(p); + snprintf(p, sizeof p, "%s/空目录", root); mkdirs(p); + + snprintf(p, sizeof p, "%s/轻小说/1.txt", root); write_file(p, "a"); + snprintf(p, sizeof p, "%s/轻小说/2.epub", root); write_file(p, "bb"); + snprintf(p, sizeof p, "%s/轻小说/notes.md", root); write_file(p, "not a book"); + snprintf(p, sizeof p, "%s/玄幻/3.txt", root); write_file(p, "ccc"); + snprintf(p, sizeof p, "%s/根下.txt", root); write_file(p, "root book"); + snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/《斗破》.txt", root); + write_file(p, "doupo"); + snprintf(p, sizeof p, "%s/B/sub/5.mobi", root); write_file(p, "mobi"); + snprintf(p, sizeof p, "%s/D/E/F/6.txt", root); write_file(p, "deep3"); + snprintf(p, sizeof p, "%s/G/H/I/J/K/L/M/7.txt", root); write_file(p, "too deep"); +} + +static void test_roundtrip(const char *root) +{ + static shelf_book scanb[SHELF_MAX_BOOKS], loadb[SHELF_MAX_BOOKS]; + shelf_scan_report r1, r2; + int n = scan_and_save(root, scanb, SHELF_MAX_BOOKS, &r1); + CHECK(n == 7, "扫描到 7 本(7.txt 超深不计)"); + CHECK(r1.depth_skipped == 1 && r1.truncated == 0, "深度超限计数=1"); + + char ip[500]; + CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得"); + struct stat st; + CHECK(stat(ip, &st) == 0 && st.st_size > 0, "索引文件已落盘"); + printf("index: %s (%lld bytes)\n", ip, (long long)st.st_size); + + int n2 = shelf_index_load(root, loadb, SHELF_MAX_BOOKS, ts_of, &r2); + CHECK(n2 == 1, "索引命中"); + CHECK(r2.nbooks == n && r2.truncated == r1.truncated && + r2.depth_skipped == r1.depth_skipped && r2.root_missing == 0, "报告重建一致"); + for (int i = 0; i < n; i++) { + const shelf_book *b = find_path(loadb, r2.nbooks, scanb[i].path); + CHECK(b != NULL, "载入含同一 path"); + if (!b) continue; + CHECK(strcmp(b->title, scanb[i].title) == 0, "title 一致"); + CHECK(strcmp(b->group, scanb[i].group) == 0, "group 一致"); + CHECK(strcmp(b->top, scanb[i].top) == 0, "top 一致"); + CHECK(strcmp(b->sub, scanb[i].sub) == 0, "sub 一致"); + CHECK(strcmp(b->sub_folder, scanb[i].sub_folder) == 0, "sub_folder 一致"); + CHECK(b->fmt == scanb[i].fmt && b->size == scanb[i].size, "fmt/size 一致"); + CHECK(b->ts == scanb[i].ts, "ts 一致"); + } + /* 排序:最近阅读优先 */ + CHECK(r2.nbooks >= 2 && strcmp(loadb[0].title, "1") == 0, + "载入后按 ts 排序(1.txt 100 在最前)"); +} + +/* I2:索引不缓存 ts —— 同一份索引,换 ts_fn 载入必须立即反映新值。 */ +static void test_ts_not_cached(const char *root) +{ + static shelf_book b[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + g_ts_boost = 0; + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) == 7, "重新扫描写索引"); + + g_ts_boost = 900; + static shelf_book lb[SHELF_MAX_BOOKS]; + CHECK(shelf_index_load(root, lb, SHELF_MAX_BOOKS, ts_of, &rep) == 1, "索引命中"); + const shelf_book *x = find_title(lb, rep.nbooks, "1"); + CHECK(x && x->ts == 1000, "载入后 ts 用当前 ts_fn(100+900),不是索引里的旧值"); + CHECK(x && strstr(x->sub, "读") != NULL, "副题随新 ts 重建(· 读)"); + g_ts_boost = 0; + + /* 真实进度文件:写 rdprog 后同一索引载入,ts/副题立即更新。 */ + char path[400]; + snprintf(path, sizeof path, "%s/根下.txt", root); + rdprog p; + memset(&p, 0, sizeof p); + p.valid = 1; p.seg = 2; p.ch = 9; p.fs = 20; p.ls = 130; p.enc = -1; p.ts = 777777; + CHECK(rdprog_save(path, &p) == 0, "写入测试进度"); + CHECK(shelf_index_load(root, lb, SHELF_MAX_BOOKS, prog_ts_of, &rep) == 1, "索引命中(真实进度)"); + const shelf_book *y = find_title(lb, rep.nbooks, "根下"); + CHECK(y && y->ts == 777777, "ts 来自 rdprog_load 现读"); + /* 删掉进度文件(注意 rdprog_save 的参数是书路径,进度文件另按 hash 落盘)。 */ + char pdir[400], phash[24], ppath[440]; + if (rdprog_dir(pdir, sizeof pdir) == 0) { + rdprog_hash(path, phash, sizeof phash); + snprintf(ppath, sizeof ppath, "%s/%s", pdir, phash); + unlink(ppath); + } +} + +/* I3:四种变更都必须让索引失效。每次变更后:先断言 load=0(不静默用陈旧索引), + * 再全量重扫 + 写新索引,断言变化可见。 */ +static void test_invalidation(const char *root) +{ + static shelf_book b[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + char p[400], q[400]; + + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引"); + + /* ① level-2 直接新增 */ + usleep(20000); + snprintf(p, sizeof p, "%s/轻小说/新书.txt", root); write_file(p, "new"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, + "level-2 新增 → 索引失效"); + int n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + CHECK(find_title(b, n, "新书") != NULL, "重扫后新书可见"); + + /* ② level-3 深处新增(Books/400+.../01、玄幻/01、斗破/) */ + usleep(20000); + snprintf(p, sizeof p, "%s/400+本高质量完本合集/01、玄幻/01、斗破/新书2.txt", root); + write_file(p, "new2"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, + "level-3 深处新增 → 索引失效"); + n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + CHECK(find_title(b, n, "新书2") != NULL, "重扫后深处新书可见"); + + /* ③ 一级目录改名 */ + usleep(20000); + snprintf(p, sizeof p, "%s/玄幻", root); + snprintf(q, sizeof q, "%s/玄幻改", root); + CHECK(rename(p, q) == 0, "改名一级目录"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, + "一级目录改名 → 索引失效"); + n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + const shelf_book *m = find_title(b, n, "3"); + CHECK(m && strcmp(m->top, "玄幻改") == 0 && strcmp(m->group, "玄幻改") == 0, + "重扫后分组键跟随新目录名"); + + /* ④ 删书 */ + usleep(20000); + snprintf(p, sizeof p, "%s/根下.txt", root); + CHECK(unlink(p) == 0, "删除一本书"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, + "删书 → 索引失效"); + n = scan_and_save(root, b, SHELF_MAX_BOOKS, &rep); + CHECK(find_title(b, n, "根下") == NULL, "重扫后删掉的书消失"); +} + +static void test_bad_index(const char *root) +{ + static shelf_book b[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + char ip[500]; + CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得"); + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "基线扫描写索引"); + + /* 截断:去掉 tail 10 字节(trailer 没了) */ + struct stat st; + CHECK(stat(ip, &st) == 0, "索引存在"); + CHECK(truncate(ip, st.st_size - 10) == 0, "截断索引"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "截断 → 拒绝"); + + /* 中间字节翻转:CRC 必须抓到 */ + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引"); + FILE *f = fopen(ip, "r+b"); + CHECK(f != NULL, "打开索引改写"); + if (f) { + CHECK(fseek(f, st.st_size / 2, SEEK_SET) == 0, "定位中部"); + int c = fgetc(f); + CHECK(c != EOF, "读中部字节"); + CHECK(fseek(f, st.st_size / 2, SEEK_SET) == 0, "回定位"); + fputc(c ^ 0x01, f); + fclose(f); + } + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "字节翻转 → 拒绝"); + + /* 垃圾内容 */ + write_file(ip, "this is not a shelf index at all, just garbage garbage garbage"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "垃圾内容 → 拒绝"); + + /* 文件缺失 */ + unlink(ip); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "文件缺失 → 拒绝"); + + /* FIFO:O_NONBLOCK 必须直接拒绝(不能挂死在 open 上) */ + CHECK(mkfifo(ip, 0644) == 0, "建 FIFO 假索引"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "FIFO → 拒绝且不阻塞"); + unlink(ip); + + /* 符号链接:O_NOFOLLOW 只认普通文件,不跟随 */ + CHECK(symlink("/etc/hostname", ip) == 0, "建符号链接假索引"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "符号链接 → 拒绝"); + unlink(ip); + + /* 索引路径是目录 */ + mkdir(ip, 0755); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "索引是目录 → 拒绝"); + rmdir(ip); + + /* 扫描中途被杀留下的 .tmp 残渣:绝不能影响正式索引的载入 */ + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引"); + char tmpip[520]; + snprintf(tmpip, sizeof tmpip, "%s.tmp", ip); + write_file(tmpip, "half written index leftover"); + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, ".tmp 残渣不影响载入"); + unlink(tmpip); + + /* 版本不符:先把版本字段改成 2 */ + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引"); + f = fopen(ip, "r+b"); + CHECK(f != NULL, "打开索引改版本"); + if (f) { + unsigned char v[4] = { 2, 0, 0, 0 }; + CHECK(fseek(f, 8, SEEK_SET) == 0, "定位版本字段"); + fwrite(v, 1, sizeof v, f); + fclose(f); + } + CHECK(shelf_index_load(root, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "版本不符 → 拒绝"); + + /* Books 根路径不符(同长度不同路径) */ + CHECK(scan_and_save(root, b, SHELF_MAX_BOOKS, &rep) > 0, "重写索引"); + char root2[400]; + snprintf(root2, sizeof root2, "%s", root); + root2[strlen(root2) - 1] = 'z'; /* Books → Bookz */ + CHECK(shelf_index_load(root2, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "根路径不符 → 拒绝"); +} + +/* I5:cap 截断 + 超深计数都要从索引如实重建。 */ +static void test_report_rebuild(const char *root) +{ + static shelf_book b[SHELF_MAX_BOOKS]; + shelf_scan_report r1; + int n = scan_and_save(root, b, 3, &r1); + CHECK(n == 3 && r1.truncated == 1, "cap=3 扫描截断标记"); + CHECK(shelf_index_load(root, b, 3, ts_of, &r1) == 1, "截断索引仍可命中"); + CHECK(r1.truncated == 1 && r1.nbooks == 3, "截断标记如实重建"); +} + +/* I6:索引不可写 → 扫描照常;commit 失败无残留。 */ +static void test_write_failure(const char *root) +{ + static shelf_book b[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + char ip[500]; + CHECK(idx_path(root, ip, sizeof ip) == 0, "索引路径可得"); + char idir[500]; + snprintf(idir, sizeof idir, "%s", ip); + char *slash = strrchr(idir, '/'); + if (slash) *slash = 0; + mkdirs(idir); + + if (geteuid() == 0) { printf("(skip 写失败用例:root 不受权限位限制)\n"); return; } + + CHECK(chmod(idir, 0500) == 0, "索引目录改为只读"); + shelf_idx_writer *w = shelf_index_writer_open(root); + CHECK(w == NULL, "只读目录 → writer_open 返回 NULL(不崩)"); + /* 生产路径:w=NULL → 扫描不采集目录,仍必须出书。 */ + int n = shelf_scan_books_ex(root, b, SHELF_MAX_BOOKS, ts_of, &rep, NULL, NULL); + CHECK(n > 0 && rep.root_missing == 0, "索引不可写时书架扫描照常"); + CHECK(chmod(idir, 0755) == 0, "恢复目录权限"); + + /* commit 失败:打开 writer(tmp 已建)后再把目录改只读 → rename 失败 → 返回 0 且无残留。 */ + unlink(ip); /* 先清掉前面用例留下的正式索引,才能断言「失败后不存在」 */ + w = shelf_index_writer_open(root); + CHECK(w != NULL, "可写时 writer_open 成功"); + if (w) { + CHECK(chmod(idir, 0500) == 0, "提交前目录改只读"); + int navail = shelf_scan_books_ex(root, b, SHELF_MAX_BOOKS, ts_of, &rep, + shelf_index_writer_dir, w); + int ok = shelf_index_writer_commit(w, b, navail, &rep); + CHECK(ok == 0, "rename 失败 → commit 返回 0"); + CHECK(chmod(idir, 0755) == 0, "恢复目录权限"); + struct stat st; + CHECK(stat(ip, &st) != 0, "commit 失败不留正式索引"); + } +} + +/* I7:空 Books 可缓存;root 不存在不写索引。 */ +static void test_empty_and_missing(const char *root) +{ + static shelf_book b[SHELF_MAX_BOOKS]; + shelf_scan_report rep; + char empty[400]; + snprintf(empty, sizeof empty, "%s_empty/Books", root); + mkdirs(empty); + int n = scan_and_save(empty, b, SHELF_MAX_BOOKS, &rep); + CHECK(n == 0 && rep.root_missing == 0, "空 Books 扫描 0 本"); + CHECK(shelf_index_load(empty, b, SHELF_MAX_BOOKS, ts_of, &rep) == 1, "空索引可命中"); + CHECK(rep.nbooks == 0, "空索引 0 本"); + + char missing[400]; + snprintf(missing, sizeof missing, "%s_nope/Books", root); + CHECK(shelf_index_load(missing, b, SHELF_MAX_BOOKS, ts_of, &rep) == 0, "根不存在 → 拒绝"); + /* writer_open 只依赖 App 数据目录(不 stat Books):根不存在时也能打开, + * 但扫描不到任何目录(ndirs=0)→ commit 必须失败且不发布半截索引。 */ + shelf_scan_report rm; + shelf_idx_writer *w = shelf_index_writer_open(missing); + int mn = shelf_scan_books_ex(missing, b, SHELF_MAX_BOOKS, ts_of, &rm, + w ? shelf_index_writer_dir : NULL, w); + CHECK(mn == 0 && rm.root_missing == 1, "根不存在 → root_missing"); + if (w) CHECK(shelf_index_writer_commit(w, b, mn, &rm) == 0, "根不存在 → commit 拒绝空索引"); +} + +int main(void) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = "/tmp"; + char tmpl[400]; + snprintf(tmpl, sizeof tmpl, "%s/m32_index_XXXXXX", base); + char *tmp = mkdtemp(tmpl); + if (!tmp) { printf("mkdtemp 失败\n"); return 2; } + snprintf(g_cfgdir, sizeof g_cfgdir, "%s/data", tmp); /* 索引落 /index */ + mkdirs(g_cfgdir); + + char root[400]; + snprintf(root, sizeof root, "%s/Books", tmp); + make_tree(root); + + test_roundtrip(root); + test_ts_not_cached(root); + test_invalidation(root); + test_bad_index(root); + test_report_rebuild(root); + test_write_failure(root); + test_empty_and_missing(root); + + printf("shelf_index_smoke: %d pass, %d fail\n", g_pass, g_fail); + char cmd[512]; + snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s_empty' '%s_nope'", tmp, root, root); + if (system(cmd) != 0) { /* 忽略清理失败 */ } + return g_fail ? 1 : 0; +}