From 2ca386973b837d37d98401fae84b69d8abdff842 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Thu, 5 Nov 2026 11:44:31 +0800 Subject: [PATCH] =?UTF-8?q?test(ncm):=20M9=20=E5=88=86=E7=B1=BB/=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E9=97=A8=E5=AE=BF=E4=B8=BB=E5=8D=95=E6=B5=8B=EF=BC=88?= =?UTF-8?q?83=20=E6=96=AD=E8=A8=80=EF=BC=89+=20=E4=BC=A0=E8=BE=93=E5=B1=82?= =?UTF-8?q?=E5=BD=92=E4=B8=80=204=20=E4=BE=8B=20+=20REPORT=20=C2=A741?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - bench/session_gate_sim.c(新):真实 core + ncm_http_request 桩,覆盖 A 错误分类矩阵(-4/-5/-6/-17/裸 mbedtls vs -13/其它)、B HTTP 状态矩阵 (401/403 vs 5xx/429 vs 3xx/4xx)、C 登录门 + 启动决策(无网→离线、无会话→扫码)、 D 真实 ncm_api 状态/业务码映射(含 -460→SVC、301/302→AUTH)、 E 真实 ncm_login_status_check/refresh 映射、F 会话密钥稳定性(换 MAC 仍可解、 旧 MAC 密文可解且自动迁移)。83 PASS / 0 FAIL,0 真实请求。 - bench/http_truncate_sim.c:追加 4 例传输层归一(握手 fatal alert→NET、握手超时→TIMEOUT、 读 fatal alert→NET、CA 失败→TLS),原 8 例截断守卫零回归(12 PASS)。 - build_ncm_sims.sh:window/search/plpage/apicache/cookie_refresh 链入 ncm_session.o (appstate/api/login 新依赖),新增 session_gate_sim 构建与运行。 - test_ncm_core.c:采用 M8 分支的 $TMPDIR 适配(Termux /tmp 不可写;合并时以 M8 为准)。 - 相关 sim 头注释同步链接命令(ncm_apicache.o/ncm_session.o)。 - REPORT §41:判定点调用链与行号、根因证据、实现、验证矩阵与诚实出入 (mipsel/真机未跑;app.c 宿主不可编译的既有原因;403/-460 取舍)。 验证:test_ncm_core_host 139 PASS/0 FAIL;session_gate_sim 83 PASS; http_truncate_sim 12 PASS;window/search/apicache/cookie_refresh sim 通过; 核心 12 TU + 应用 13 TU(-Wall -Wextra)0 warning。 --- patch_v2/ncm_app/REPORT.md | 107 ++++++ patch_v2/ncm_app/bench/http_truncate_sim.c | 55 ++- patch_v2/ncm_app/bench/pagination_sim.c | 3 +- patch_v2/ncm_app/bench/playlist_race_sim.c | 3 +- patch_v2/ncm_app/bench/search_sim.c | 3 +- patch_v2/ncm_app/bench/session_gate_sim.c | 422 +++++++++++++++++++++ patch_v2/ncm_app/bench/window_sim.c | 3 +- patch_v2/ncm_app/build_ncm_sims.sh | 22 +- 8 files changed, 606 insertions(+), 12 deletions(-) create mode 100644 patch_v2/ncm_app/bench/session_gate_sim.c diff --git a/patch_v2/ncm_app/REPORT.md b/patch_v2/ncm_app/REPORT.md index 393121e..b444297 100644 --- a/patch_v2/ncm_app/REPORT.md +++ b/patch_v2/ncm_app/REPORT.md @@ -3480,3 +3480,110 @@ MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires - **live 网络测试跳过**:Termux 环境 HTTPS 到 music.163.com 返回 rc=-5(网络/证书被拒),2 项 SKIP;加密/缓存/API 解析等离线项全部 PASS。 - **产物不入库**:`ncm` 二进制、`meow_ui.a`、vendor、`out/` 均只在本机产出;仓库中原本被跟踪的旧 `ncm`/`test_ncm_core_host` 已恢复为 HEAD 版本,避免把本机构建产物带进提交。 - 卡上保留两份备份(`ncm.bak` 与 `ncm.bak.<旧md5>`),需要回滚可 `cp` 任一覆盖。 + +## 41. M9:断网保持登录态 + 离线模式(账号态不被网络故障否决)(2026-11-05) + +> 用户原话:「网易云有个问题,只要设备一断网 它就会自动退出到扫码登录界面,而不会保持当前账号状态 +> 切换到离线模式。这样导致昨天刚做的那个缓存列表形同虚设。估计是没有判断网络状态 把网络访问不可达 +> 当成了登录状态过期」。 +> +> 目标:网络不可达/超时/DNS/连接失败/服务端 5xx → **保持账号态**、进入离线模式(界面可见提示、 +> 缓存列表可打开可播放、不弹扫码),网络恢复后回在线;只有服务端**明确**未登录/鉴权失败 +> (HTTP 401、业务码 301/302)才沿用原退出登录逻辑。排除:不做离线下载/搜索等在线功能。 +> 红线:0warning;宿主可验证错误分类单测必过;不碰真机;不 push。 +> +> 编号说明:§40 为 M8 交叉编译/热更(本分支已 rebase 到含 §40 的 main),本节顺延 §41。 + +### 41.1 判定点盘点:谁会把用户踢回扫码 + +| # | 位置 | 触发 | 后果 | +|---|------|------|------| +| ① | `app/app.c:176-180` `app_on_tick` | `account_cookie_need_relogin()`(仅由 `account_cookie_maybe_refresh` 置位) | `appstate_logout()`(删 `session.enc`、清 uid/vip)+ `meow_view_page_replace(ACT_LOGIN)` | +| ② | `app/logic/appstate.c:231-256` `appstate_ensure_uid` | `ncm_login_status_check` 返回 `NCM_ERR_AUTH` | `ncm_ctx_clear_cookie` + `logged_in=0`(首页转「未登录/会话过期」) | +| ③ | `app/app.c:245-252` 启动根页 + `app/logic/appstate.c:100` | `g_st.logged_in = (cookie[0] != 0)`;cookie 只在 `ncm_ctx_load_cookie` 解密成功时非空 | cookie 解密失败 → 直接进 `ACT_LOGIN`(扫码页) | +| ④ | `app/ui/settings.c` | 用户点「退出登录」 | 主动登出(保留原逻辑) | + +调用链(①):`home_worker`(`app/ui/home.c`) → `account_cookie_maybe_refresh`(`app/logic/account.c:181`) +→ `ncm_login_refresh`(`core/ncm_login.c:190`) → 返回码 → `s_cookie_need_relogin=1`(`account.c:210-215`) +→ `app_on_tick`(`app.c:176`) → `appstate_logout()` + 扫码页。 +调用链(②):`appstate_load_playlists`(`appstate.c:227`) → `appstate_ensure_uid`(`appstate.c:231`) +→ `ncm_login_status_check`(`core/ncm_login.c:142`) → 返回码 → 清 cookie(`appstate.c:246-250`)。 + +### 41.2 根因:哪些失败被误判成「登录失效」 + +代码级证据(均为本批修改前的行为): + +1. **任何业务码非 200 都当会话失效**:`core/ncm_login.c:227`(改前)`if (code != 200) return NCM_ERR_AUTH;` + —— `-460`(风控/请求被拒,换网/恢复网络时常见)也被判 AUTH。`core/ncm_api.c:104`(改前) + 把 `-460/2000/201` 一律映射 `NCM_ERR_AUTH`。→ 网络环境变化触发的风控被当成「会话过期」。 +2. **HTTP 状态被完全忽略**:`api_post`(`core/ncm_api.c:123`)与 `ncm_login_status_check` + (`core/ncm_login.c:162`)只看 body 里的业务码。HTTP 401/403 与 5xx/网关 HTML 都落进 + 「JSON 解析失败 / 缺字段」;反过来 5xx 与网络故障无法区分,离线判定拿不到可靠输入。 +3. **mbedtls 裸负码穿透**:`http_run` 的握手/写入失败分支 `goto done` 直接把 mbedtls 的负码 + (如 `-0x7280`)返回给上层(`core/ncm_http.c` 改前 `done:` 段);上层按 `NCM_ERR_*` 比较, + 落进「未知错误」,既判不了离线也判不了鉴权。 +4. **启动无网的持久化缺陷(与错误分类无关,但症状一致)**:会话 cookie 的加密密钥是 + `sha256(首选网卡 MAC + 盐)`(`core/ncm_store.c:102-135 read_mac` 遍历 `/sys/class/net` 取首个 + 非 lo 网卡 + `151-171` 派生)。网卡集合/readdir 顺序随网络状态变化(断网时 Wi-Fi 网卡可能 + 从 `/sys/class/net` 消失,或选到另一块网卡)→ 派生密钥变化 → `session.enc` 解密失败 + (`ncm_store.c:260-268` → `ncm_ctx_load_cookie` → `appstate.c:100`)→ cookie 为空 → + `logged_in=0` → **启动即进扫码页**。这正是「一断网就退到扫码」在启动侧的机制。 + +结论:设计上缺少「失败分类」这一层——传输层/服务端暂不可用、鉴权失败、数据解析失败共用 +「负错误码」这一个通道,登录门(`app.c` / `appstate_ensure_uid`)只能按 AUTH 一个值判断, +于是任何上游映射偏差都会变成「退出登录」。 + +### 41.3 实现 + +| 文件 | 改动 | +|---|---| +| `core/ncm_session.h/.c`(新) | 纯逻辑分类层:`ncm_err_classify`(NETWORK/AUTH/OTHER;`-4/-5/-6/-17` 与历史 mbedtls 裸负码→NETWORK,仅 `-13`→AUTH)、`ncm_http_status_class/err`(401/403→AUTH;408/425/429/5xx→SVC;其它非 2xx→HTTP)、`ncm_gate_decide`(一次请求→在线/离线/退登录/保持)、`ncm_session_boot_decide`(有本地会话就先信它,无网→离线,绝不因连不上进扫码)。 | +| `core/ncm_common.h` | 新增 `NCM_ERR_SVC = -17`(服务端 5xx/429/-460:暂不可用,可重试,**不是**登录失效)。 | +| `core/ncm_http.c` | 新增 `http_ret_norm`:超时→`NCM_ERR_TIMEOUT`、证书→`NCM_ERR_TLS`、其它 mbedtls 负码→`NCM_ERR_NET`,在 `done:` 统一归一(532 行)。 | +| `core/ncm_api.c` | `api_status_gate`:非 2xx 先按状态定性(401/403→AUTH、5xx/429→SVC、其它→HTTP),不再把 5xx HTML 丢给 JSON 解析;业务码映射加 302→AUTH、`-460`→SVC(113-141 行)。 | +| `core/ncm_login.c` | `ncm_login_status_check` / `ncm_login_refresh` 先过 HTTP 状态门,再按业务码:301/302→AUTH、`-460`→SVC、其它→HTTP(163-249 行)。`-460` 不再触发退扫码。 | +| `core/ncm_store.c/.h` | 会话密钥改为设备私有 `/.mkey` 派生(`key_from_mkey`/`session_key`,175-193 行);旧 MAC 派生密文仍可解(`cookie_decrypt_sel`,270 行)且 `load_session` 命中旧密钥时自动重写迁移(306-317 行)。 | +| `core/ncm_apicache.c/.h` | 新增命中计数 `ncm_apicache_hits()`:离线时读到 120s 旧缓存仍是 `NCM_OK`,不能据此判「已回在线」。 | +| `app/ncm_app_internal.h` | `ncm_app_state_t` 增 `offline`;新增 `appstate_session_gate/is_offline/offline_set`。 | +| `app/logic/appstate.c` | 新增离线状态机(177-197 行);`appstate_ensure_uid` 仅 AUTH 清 cookie,网络类只切离线(231-256 行);init/logout 清零离线态。 | +| `app/app.c` | 启动根页改由 `ncm_session_boot_decide` 决定并打日志(245-252 行);`need_relogin` 路径保持(仅 AUTH 会置位)。 | +| `app/ui/home.c` | worker 结果经 `appstate_session_gate`(缓存命中不算回在线,271-272 行);个人卡右侧「离线模式」红标 + 页脚提示 + 空态「离线模式/可播放已缓存」;离线时每 30s 静默重试一次(强制绕过接口缓存,301-312 行)。 | +| `app/ui/playlist.c` | 首屏结果同步会话/离线态(锁外,333 行);空态「离线模式」。 | +| `app/ui/search.c` / `app/ui/player.c` / `app/ui/cache.c` | 离线提示文案:搜索需网络、播放页「离线模式:网络不可用,可播放已缓存」、缓存页页脚「· 离线模式(可播放)」。 | +| `build_core.sh` / `build_ncm.sh` | 核心源列表加 `core/ncm_session.c`。 | +| `build_ncm_sims.sh` | window/search/plpage/apicache/cookie_refresh 链入 `ncm_session.o`;新增 `session_gate_sim` 构建/运行。 | +| `bench/session_gate_sim.c`(新) | 分类矩阵 + HTTP 状态矩阵 + 登录门/启动门矩阵 + 真实 `ncm_api`/`ncm_login` 状态/业务码映射 + 会话密钥稳定性/旧密文迁移(83 断言)。 | +| `bench/http_truncate_sim.c` | 追加 4 个传输层归一用例(握手 fatal alert→NET、握手超时→TIMEOUT、读 fatal alert→NET、CA 失败→TLS)。 | + +行为不变量(本次确立):**只有 `NCM_ERR_AUTH`(HTTP 401/403、业务码 301/302/profile 为空)会清 +`session.enc` 并回扫码;其余一切失败最多进入离线模式,绝不动账号态。** + +### 41.4 验证(宿主,0 真实 NCM 请求) + +| 项 | 结果 | +|---|---| +| `bench/session_gate_sim`(新,真实 core + `ncm_http_request` 桩) | **83 PASS / 0 FAIL**:A 分类矩阵 11 例(-5/-4/-6/-17/裸 mbedtls/…)、B HTTP 状态 14 例、C 登录门 10 + 启动门 6 例、D `ncm_api` 15 例(401/403、5xx/429、业务 301/302、-460、非 JSON、传输码)、E `login_status` 8 + `login_refresh` 8 例、F 会话密钥 9 例(换 MAC 仍可解、旧密文可解并自动迁移) | +| `bench/http_truncate_sim` | **12 PASS**(原 8 截断用例零回归 + 新 4 个传输层归一用例) | +| `test/test_ncm_core_host`(全量核心库) | **139 PASS / 0 FAIL**(`TMPDIR` 指向可写目录;含 crypto/store/cache/tag/api/降级链全量) | +| `bench/window_sim` / `bench/search_sim` | PASS(window 需可写 TMPDIR:本机 `/tmp` 不可写时盘缓存用例环境性失败,换 `$TMPDIR` 后 6000/6000 落盘通过) | +| `bench/apicache_sim` / `bench/cookie_refresh_sim` | PASS(apicache 同样仅盘缓存文件路径受 `/tmp` 影响,换可写目录后通过) | +| 宿主告警 | 核心 13 个 TU(含新 `ncm_session.c`)+ 应用 13 个 TU(login/home/playlist/player/search/cache/appstate/account/settings/queue/minibar/chrome/colors)`-Wall -Wextra` **0 warning** | +| mipsel / 真机 | **未跑**(本机无 zig 交叉链,M8 适配层未并入;设备当前离线,真机验证后置) | + +### 41.5 诚实出入 / 遗留 + +- **5xx 归类为「离线/可重试」**(`NCM_ERR_SVC`),UI 统一显示「离线模式」——网络其实可能是通的, + 只是服务端暂不可用;文案取「可播放已缓存」这一用户可执行动作,未再细分。 +- **HTTP 403 仍按 AUTH**(标准语义:服务端理解请求但拒绝)。若真机遇到 CDN/WAF 的 403,可能造成 + 一次误登出;NCM 的风控通常以业务码 `-460` 表达(已改为 SVC)。如真机复现可再收窄为「仅 401」。 +- **离线时接口缓存命中不算「回在线」**:`ncm_apicache_hits()` 只解决 UI/重试节奏误判;离线恢复 + 探测(home 每 30s)强制 `ncm_api_cache_refresh_path` 绕过缓存,确保成功即真在线。 +- **离线播放未缓存曲目**仍走原 `song_url` 重试(15s 超时 ×3),未改播放重试策略(不属本任务); + 已缓存曲目离线可播(`song_local_file` 先查缓存)。 +- **`app/app.c` 未能宿主编译**:其 SIGSEGV 定位处理器用 MIPS `ucontext.gregs[]`,aarch64 宿主 + 无法编译(既有问题,`build_ncm_host.sh` 本身也排除 app.c)。本批对该文件的改动仅十余行 + (`ncm_gate_t` + 日志 + 三元切页),交叉构建时需复核 0 warning。 +- **会话密钥迁移**:旧 `session.enc`(MAC 派生)在当前 MAC 未变时可解并自动重写为新密钥; + 若升级时 MAC 已变化(例如换网卡后首次启动),旧会话仍会解不开 —— 这是旧方案的固有缺陷, + 无法事后补救;迁移后再不会因网络状态变化丢会话。 +- **未部署/未 push**:设备离线,真机联调后置;本节所有验证均在宿主完成。 diff --git a/patch_v2/ncm_app/bench/http_truncate_sim.c b/patch_v2/ncm_app/bench/http_truncate_sim.c index 16da875..a1dce32 100644 --- a/patch_v2/ncm_app/bench/http_truncate_sim.c +++ b/patch_v2/ncm_app/bench/http_truncate_sim.c @@ -15,6 +15,12 @@ * chunked 完整(0 分块末端) → BS_DONE → NCM_OK * chunked 中途关闭 → 截断 → 非 NCM_OK(NCM_ERR_IO) * + * M9 追加(传输层错误归一):mbedtls 裸负码不得原样穿透给上层—— + * 握手失败(fatal alert) → NCM_ERR_NET + * 握手超时 → NCM_ERR_TIMEOUT + * 读阶段 fatal alert(对端 reset/拒绝) → NCM_ERR_NET + * CA 加载/校验失败(verify_ca=1) → NCM_ERR_TLS + * * 编译(在 ncm_app 下): * gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/mbedtls/include \ * -c bench/http_truncate_sim.c -o /tmp/t.o @@ -48,6 +54,8 @@ static const tr_chunk_t *g_script; static int g_script_n; static int g_script_i; static int g_close_rc; /* 脚本耗尽后的 ssl_read 返回值 */ +static int g_handshake_rc; /* M9:0=正常;否则握手直接失败(模拟网络/TLS 异常) */ +static int g_ca_fail; /* M9:1=CA 加载失败(verify_ca=1 时应判 NCM_ERR_TLS) */ static void tr_script(const tr_chunk_t *chunks, int n, int close_rc) { g_script = chunks; g_script_n = n; g_script_i = 0; g_close_rc = close_rc; @@ -110,13 +118,13 @@ void mbedtls_ssl_set_bio(mbedtls_ssl_context *ssl, void *p_bio, mbedtls_ssl_recv_timeout_t *f_recv_timeout) { (void)ssl; (void)p_bio; (void)f_send; (void)f_recv; (void)f_recv_timeout; } -int mbedtls_ssl_handshake(mbedtls_ssl_context *ssl) { (void)ssl; return 0; } +int mbedtls_ssl_handshake(mbedtls_ssl_context *ssl) { (void)ssl; return g_handshake_rc; } void mbedtls_ssl_free(mbedtls_ssl_context *ssl) { (void)ssl; } void mbedtls_ssl_config_free(mbedtls_ssl_config *conf) { (void)conf; } void mbedtls_x509_crt_init(mbedtls_x509_crt *crt) { (void)crt; } int mbedtls_x509_crt_parse_file(mbedtls_x509_crt *crt, const char *path) { - (void)crt; (void)path; return 0; /* CA 加载成功(validate=0 时不走此径,桩兜底) */ + (void)crt; (void)path; return g_ca_fail ? -1 : 0; /* CA 加载成功/失败(M9 用例可控) */ } void mbedtls_x509_crt_free(mbedtls_x509_crt *crt) { (void)crt; } @@ -136,12 +144,13 @@ static int on_body(void *ctx, const unsigned char *data, size_t len) { /* 运行一个用例:装填脚本 → 发起 GET 下载 → 记录 rc / body_len / content_length */ typedef struct { int rc; size_t body_len; size_t content_length; } case_result_t; -static case_result_t run_case(const tr_chunk_t *script, int script_n, int close_rc) { +static case_result_t run_case_ex(const tr_chunk_t *script, int script_n, int close_rc, + int verify_ca) { ncm_http_req_t req; memset(&req, 0, sizeof req); req.url = "https://ex.test/song.mp3"; req.method = "GET"; - req.verify_ca = 0; /* 跳过 CA 校验,聚焦 body 终态判定 */ + req.verify_ca = verify_ca; /* 0=跳过 CA 校验,聚焦 body 终态判定 */ req.timeout_ms = 3000; sink_cb_t sink; memset(&sink, 0, sizeof sink); @@ -157,6 +166,10 @@ static case_result_t run_case(const tr_chunk_t *script, int script_n, int close_ return r; } +static case_result_t run_case(const tr_chunk_t *script, int script_n, int close_rc) { + return run_case_ex(script, script_n, close_rc, 0); +} + static int g_fail = 0; static void check(const char *name, int ok, const char *detail) { if (!ok) g_fail++; @@ -286,6 +299,40 @@ int main(void) { (r.rc != NCM_OK) && (r.rc == NCM_ERR_IO), dbg); } + /* ---- 场景 9(M9):握手 fatal alert(对端 reset/拒绝)→ 归一为 NCM_ERR_NET ---- */ + { + g_handshake_rc = MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE; + case_result_t r = run_case(NULL, 0, 0); + g_handshake_rc = 0; + snprintf(dbg, sizeof dbg, "rc=%d", r.rc); + check("handshake_fatal_alert -> NCM_ERR_NET", r.rc == NCM_ERR_NET, dbg); + } + + /* ---- 场景 10(M9):握手超时 → NCM_ERR_TIMEOUT ---- */ + { + g_handshake_rc = MBEDTLS_ERR_SSL_TIMEOUT; + case_result_t r = run_case(NULL, 0, 0); + g_handshake_rc = 0; + snprintf(dbg, sizeof dbg, "rc=%d", r.rc); + check("handshake_timeout -> NCM_ERR_TIMEOUT", r.rc == NCM_ERR_TIMEOUT, dbg); + } + + /* ---- 场景 11(M9):读阶段 fatal alert(连接被 reset)→ NCM_ERR_NET ---- */ + { + case_result_t r = run_case(NULL, 0, MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE); + snprintf(dbg, sizeof dbg, "rc=%d", r.rc); + check("read_fatal_alert -> NCM_ERR_NET", r.rc == NCM_ERR_NET, dbg); + } + + /* ---- 场景 12(M9):CA 不可用(verify_ca=1)→ NCM_ERR_TLS(不静默放行) ---- */ + { + g_ca_fail = 1; + case_result_t r = run_case_ex(NULL, 0, 0, 1); + g_ca_fail = 0; + snprintf(dbg, sizeof dbg, "rc=%d", r.rc); + check("ca_load_fail -> NCM_ERR_TLS", r.rc == NCM_ERR_TLS, dbg); + } + printf("\n== HTTP TRUNCATE SIM: %s ==\n", g_fail ? "FAIL" : "ALL PASS"); return g_fail ? 1 : 0; } diff --git a/patch_v2/ncm_app/bench/pagination_sim.c b/patch_v2/ncm_app/bench/pagination_sim.c index 94655df..c98603b 100644 --- a/patch_v2/ncm_app/bench/pagination_sim.c +++ b/patch_v2/ncm_app/bench/pagination_sim.c @@ -17,7 +17,8 @@ * -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ps_app.o * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ * -Icore -I../vendor/jsmn -c app/logic/songmeta.c -o /tmp/ps_meta.o - * $CC_HOST -O2 -Wl,--gc-sections -o pagination_sim /tmp/ps.o /tmp/ps_app.o /tmp/ps_meta.o -lpthread + * $CC_HOST -O2 -Wl,--gc-sections -o pagination_sim /tmp/ps.o /tmp/ps_app.o /tmp/ps_meta.o \ + * out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread * 运行:./pagination_sim (0 网络;PASS/FAIL 退出码) */ #include "../app/ncm_app_internal.h" diff --git a/patch_v2/ncm_app/bench/playlist_race_sim.c b/patch_v2/ncm_app/bench/playlist_race_sim.c index 47c5b5f..ed004b2 100644 --- a/patch_v2/ncm_app/bench/playlist_race_sim.c +++ b/patch_v2/ncm_app/bench/playlist_race_sim.c @@ -21,7 +21,8 @@ * -c bench/playlist_race_sim.c -o /tmp/pr.o * GCC -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icore -Ivendor/jsmn \ * -c app/logic/appstate.c -o /tmp/pr_app.o - * GCC -O2 -Wl,--gc-sections -o playlist_race_sim /tmp/pr.o /tmp/pr_app.o -lpthread + * GCC -O2 -Wl,--gc-sections -o playlist_race_sim /tmp/pr.o /tmp/pr_app.o \ + * out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread * 运行:./playlist_race_sim (0 网络;PASS/FAIL 退出码) */ #include "../app/ncm_app_internal.h" diff --git a/patch_v2/ncm_app/bench/search_sim.c b/patch_v2/ncm_app/bench/search_sim.c index 9dec476..961085a 100644 --- a/patch_v2/ncm_app/bench/search_sim.c +++ b/patch_v2/ncm_app/bench/search_sim.c @@ -10,7 +10,8 @@ * -Icore -c bench/search_sim.c -o /tmp/ss.o * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ * -Icore -c app/logic/appstate.c -o /tmp/ss_app.o - * $CC_HOST -O2 -Wl,--gc-sections -o search_sim /tmp/ss.o /tmp/ss_app.o -lpthread + * $CC_HOST -O2 -Wl,--gc-sections -o search_sim /tmp/ss.o /tmp/ss_app.o \ + * out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread * 运行:./search_sim (0 网络;PASS/FAIL 退出码) */ #include "../app/ncm_app_internal.h" diff --git a/patch_v2/ncm_app/bench/session_gate_sim.c b/patch_v2/ncm_app/bench/session_gate_sim.c new file mode 100644 index 0000000..0db42ea --- /dev/null +++ b/patch_v2/ncm_app/bench/session_gate_sim.c @@ -0,0 +1,422 @@ +/* bench/session_gate_sim.c — M9 离线会话保持 / 登录门决策 宿主单测(0 真实网络) + * + * 覆盖(真实 core 代码 + 网络桩,0 请求): + * A. 错误分类矩阵 ncm_err_classify:-4/-5/-6/-17 网络类;-13 鉴权;JSON/IO/其它 = other; + * 历史泄漏的 mbedtls 裸负码按网络类兜底。 + * B. HTTP 状态分类 ncm_http_status_class / ncm_http_status_err: + * 200/204 → ok;401/403 → auth;408/425/429/5xx → transient(SVC);3xx/其它 4xx → error(HTTP)。 + * C. 登录门 ncm_gate_decide / ncm_session_boot_decide:在线/离线/鉴权失败/启动无网/无会话。 + * D. 真实 ncm_api(ncm_http_request 桩)HTTP 状态与业务码映射: + * 401/403 与业务 301/302 → AUTH(退登录);5xx/429、业务 -460 → SVC(保登录态/离线); + * HTML 体 → JSON(other);其它业务码 → HTTP(other)。 + * E. 真实 ncm_login_status_check / ncm_login_refresh 同上(AUTH 只在服务端明确否定时)。 + * F. 会话加密密钥稳定性(M9 根因回归):session.enc 保存后改变网卡 MAC(NCM_MAC 桩)仍能解出 + * cookie;旧版 MAC 派生密文可解且自动迁移到 .mkey 密钥。 + * + * 编译(在 ncm_app 下;先 bash build_core.sh 生成宿主核心对象): + * gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/jsmn -Ivendor/mbedtls/include \ + * -c bench/session_gate_sim.c -o out/chk/sg.o + * gcc -O2 -o session_gate_sim out/chk/sg.o \ + * out/core-host/ncm_api.o out/core-host/ncm_login.o out/core-host/ncm_req.o \ + * out/core-host/ncm_crypto.o out/core-host/ncm_json.o out/core-host/ncm_common.o \ + * out/core-host/ncm_store.o out/core-host/ncm_apicache.o out/core-host/ncm_session.o \ + * out/core-host/jsmn_impl.o out/core-host/v_qrcodegen.o \ + * -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm + * TMPDIR 可用(Termux /tmp 不可写时用 $TMPDIR 或 HOME/.cache)。 + */ +#include +#include +#include +#include +#include +#include + +#include "ncm.h" +#include "ncm_http.h" +#include "mbedtls/aes.h" + +static int g_fail = 0; +#define CHECK(cond, ...) do { \ + int _ok = (cond) ? 1 : 0; \ + printf(" %s ", _ok ? "PASS" : "FAIL"); printf(__VA_ARGS__); printf("\n"); \ + if (!_ok) g_fail++; \ +} while (0) + +/* ============================================================ + * ncm_http_request 桩:按 g_stub_* 脚本返回(不触碰网络/文件) + * ============================================================ */ +static int g_stub_rc; /* 非 0:直接返回该传输层错误码 */ +static int g_stub_status; /* HTTP 状态 */ +static const char *g_stub_body; /* 响应体(缺省 "") */ +static const char *g_stub_cookie; /* Set-Cookie 头(可 NULL) */ +static int g_http_calls; + +int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp) +{ + (void)req; + g_http_calls++; + memset(resp, 0, sizeof *resp); + if (g_stub_rc) return g_stub_rc; + const char *body = g_stub_body ? g_stub_body : ""; + size_t n = strlen(body); + if (n >= buf_sz) n = buf_sz - 1; + memcpy(buf, body, n); + resp->body = buf; + resp->body_len = n; + resp->status = g_stub_status; + if (g_stub_cookie) { + /* 真实响应每个 cookie 一条 Set-Cookie 头;测试串用 "; " 分隔多 cookie。 */ + size_t o = 0; + const char *p = g_stub_cookie; + while (*p && o < sizeof resp->headers) { + const char *sep = strstr(p, "; "); + size_t n = sep ? (size_t)(sep - p) : strlen(p); + int hn = snprintf(resp->headers + o, sizeof resp->headers - o, + "Set-Cookie: %.*s\r\n", (int)n, p); + if (hn <= 0) break; + o += (size_t)hn; + if (!sep) break; + p = sep + 2; + } + resp->headers_len = o; + } + return NCM_OK; +} + +static void stub(int status, const char *body, const char *cookie_set) +{ + g_stub_rc = NCM_OK; g_stub_status = status; g_stub_body = body; g_stub_cookie = cookie_set; +} +static void stub_err(int rc) +{ + g_stub_rc = rc; g_stub_status = 0; g_stub_body = NULL; g_stub_cookie = NULL; +} + +/* ncm_http_parse_set_cookie:本 sim 不链 ncm_http.o(ncm_http_request 已桩化), + * 这里放一份与 core/ncm_http.c 相同的实现,供 ncm_login_refresh 提取 Set-Cookie。 */ +int ncm_http_parse_set_cookie(const char *hdrs, size_t len, char *out, size_t out_sz) +{ + if (!hdrs || !out || out_sz == 0) return -1; + out[0] = 0; + size_t o = 0; + int count = 0; + const char *p = hdrs; + const char *end = hdrs + len; + while ((p = strstr(p, "Set-Cookie:")) != NULL && p < end) { + const char *val = p + strlen("Set-Cookie:"); + while (*val == ' ' || *val == '\t') val++; + const char *semi = strchr(val, ';'); + size_t nlen = semi ? (size_t)(semi - val) : strlen(val); + while (nlen && (val[nlen - 1] == ' ' || val[nlen - 1] == '\t')) nlen--; + if (nlen == 0) { p = val; continue; } + if (o > 0) { if (o + 2 >= out_sz) break; out[o++] = ';'; out[o++] = ' '; } + if (o + nlen >= out_sz) break; + memcpy(out + o, val, nlen); + o += nlen; + count++; + p = val; + } + out[o] = 0; + return count; +} + +/* ============================================================ + * A. 错误分类 + * ============================================================ */ +static void test_err_class(void) +{ + printf("== A. 错误分类(-5/-4/-6/超时/DNS/连接拒/5xx vs 401/业务 301/302)==\n"); + struct { int rc; ncm_fail_class_t want; const char *why; } m[] = { + { NCM_OK, NCM_FAIL_NONE, "NCM_OK" }, + { NCM_ERR_NET, NCM_FAIL_NETWORK, "-4 网络错误(DNS/连接拒绝/连接中断)" }, + { NCM_ERR_TLS, NCM_FAIL_NETWORK, "-5 TLS 错误(历史真机 eapi 域 reset 即此码)" }, + { NCM_ERR_TIMEOUT, NCM_FAIL_NETWORK, "-6 超时" }, + { NCM_ERR_SVC, NCM_FAIL_NETWORK, "-17 服务端 5xx/429/-460 暂不可用" }, + { NCM_ERR_AUTH, NCM_FAIL_AUTH, "-13 服务端明确未登录/鉴权失败" }, + { NCM_ERR_JSON, NCM_FAIL_OTHER, "-9 JSON(数据问题,不登出)" }, + { NCM_ERR_HTTP, NCM_FAIL_OTHER, "-7 HTTP 非 2xx(其它)" }, + { NCM_ERR_IO, NCM_FAIL_OTHER, "-3 文件/截断 IO" }, + { NCM_ERR_NOTFOUND, NCM_FAIL_OTHER, "-10 缺字段" }, + { -0x7280, NCM_FAIL_NETWORK, "mbedtls 裸负码(历史泄漏)→ 网络类兜底" }, + }; + for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) { + ncm_fail_class_t got = ncm_err_classify(m[i].rc); + CHECK(got == m[i].want, "classify(%d)=%s 期望 %s [%s]", m[i].rc, + ncm_fail_class_name(got), ncm_fail_class_name(m[i].want), m[i].why); + } + CHECK(ncm_err_is_network(NCM_ERR_TLS) == 1 && ncm_err_is_network(NCM_ERR_AUTH) == 0, + "ncm_err_is_network: TLS=1 / AUTH=0"); +} + +/* ============================================================ + * B. HTTP 状态 → 分类/错误码 + * ============================================================ */ +static void test_http_class(void) +{ + printf("== B. HTTP 状态分类(401/403 vs 5xx vs 3xx/4xx)==\n"); + struct { int st; ncm_http_cls_t cls; int err; } m[] = { + { 200, NCM_HTTP_CLS_OK, NCM_OK }, + { 204, NCM_HTTP_CLS_OK, NCM_OK }, + { 401, NCM_HTTP_CLS_AUTH, NCM_ERR_AUTH }, + { 403, NCM_HTTP_CLS_AUTH, NCM_ERR_AUTH }, + { 408, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC }, + { 425, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC }, + { 429, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC }, + { 500, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC }, + { 502, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC }, + { 503, NCM_HTTP_CLS_TRANSIENT, NCM_ERR_SVC }, + { 301, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP }, + { 302, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP }, + { 400, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP }, + { 404, NCM_HTTP_CLS_ERROR, NCM_ERR_HTTP }, + }; + for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) { + ncm_http_cls_t c = ncm_http_status_class(m[i].st); + int e = ncm_http_status_err(m[i].st); + CHECK(c == m[i].cls && e == m[i].err, "status %d -> cls=%d err=%d(期望 %d/%d)", + m[i].st, (int)c, e, (int)m[i].cls, m[i].err); + } +} + +/* ============================================================ + * C. 登录门决策 + * ============================================================ */ +static void test_gate(void) +{ + printf("== C. 登录门决策(离线/在线/鉴权失败/启动无网)==\n"); + struct { int logged_in; int rc; ncm_gate_t want; const char *why; } m[] = { + { 1, NCM_OK, NCM_GATE_ONLINE, "请求成功 → 在线" }, + { 1, NCM_ERR_NET, NCM_GATE_OFFLINE, "断网/不可达 → 离线(保持账号态)" }, + { 1, NCM_ERR_TLS, NCM_GATE_OFFLINE, "TLS 失败 → 离线" }, + { 1, NCM_ERR_TIMEOUT, NCM_GATE_OFFLINE, "超时 → 离线" }, + { 1, NCM_ERR_SVC, NCM_GATE_OFFLINE, "服务端 5xx/-460 → 离线(可重试,不登出)" }, + { 1, NCM_ERR_AUTH, NCM_GATE_RELOGIN, "业务 301/HTTP 401 → 退登录" }, + { 1, NCM_ERR_JSON, NCM_GATE_HOLD, "解析失败 → 保持现状(不登出)" }, + { 1, NCM_ERR_NOTFOUND, NCM_GATE_HOLD,"缺字段 → 保持现状" }, + { 0, NCM_OK, NCM_GATE_RELOGIN, "本地无会话 → 扫码" }, + { 0, NCM_ERR_NET, NCM_GATE_RELOGIN, "本地无会话(无网也一样扫码)" }, + }; + for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) { + ncm_gate_t got = ncm_gate_decide(m[i].logged_in, m[i].rc); + CHECK(got == m[i].want, "gate(logged_in=%d, rc=%d)=%s 期望 %s [%s]", + m[i].logged_in, m[i].rc, ncm_gate_name(got), ncm_gate_name(m[i].want), m[i].why); + } + + printf("-- 启动决策(有本地会话就先信它;无网也进 home/缓存)--\n"); + struct { int has_cookie; int probe_rc; ncm_gate_t want; const char *why; } b[] = { + { 1, NCM_OK, NCM_GATE_ONLINE, "有会话 + 探测成功 → 在线" }, + { 1, NCM_ERR_NET, NCM_GATE_OFFLINE, "启动时就没网 → 离线(绝不进扫码页)" }, + { 1, NCM_ERR_TLS, NCM_GATE_OFFLINE, "启动时 TLS 失败 → 离线" }, + { 1, NCM_ERR_AUTH, NCM_GATE_RELOGIN, "服务端明确否定 → 扫码" }, + { 1, NCM_ERR_JSON, NCM_GATE_ONLINE, "不确定的失败 → 不赶人,先进 App" }, + { 0, NCM_OK, NCM_GATE_RELOGIN, "无本地会话 → 扫码" }, + }; + for (size_t i = 0; i < sizeof b / sizeof b[0]; i++) { + ncm_gate_t got = ncm_session_boot_decide(b[i].has_cookie, b[i].probe_rc); + CHECK(got == b[i].want, "boot(cookie=%d, probe=%d)=%s 期望 %s [%s]", + b[i].has_cookie, b[i].probe_rc, ncm_gate_name(got), ncm_gate_name(b[i].want), + b[i].why); + } +} + +/* ============================================================ + * D. 真实 ncm_api:HTTP 状态 / 业务码 → 错误码 + * ============================================================ */ +static void test_api_map(void) +{ + printf("== D. ncm_api(user_playlist)状态/业务码映射 ==\n"); + ncm_apicache_init(NULL); + const char *json_ok = "{\"code\":200,\"playlist\":[{\"id\":1,\"name\":\"a\",\"trackCount\":2}]}"; + struct { int status; const char *body; int stub_err; int want; const char *why; } m[] = { + { 200, json_ok, 0, NCM_OK, "200+code200 → 成功" }, + { 401, "401", 0, NCM_ERR_AUTH, "HTTP 401 → AUTH(退登录)" }, + { 403, "{}", 0, NCM_ERR_AUTH, "HTTP 403 → AUTH" }, + { 500, "oops", 0, NCM_ERR_SVC, "HTTP 500(HTML)→ SVC(保留登录态)" }, + { 503, "{\"code\":200}", 0, NCM_ERR_SVC, "HTTP 503 → SVC" }, + { 429, "", 0, NCM_ERR_SVC, "HTTP 429 → SVC" }, + { 404, "{}", 0, NCM_ERR_HTTP, "HTTP 404 → HTTP(不登出)" }, + { 200, "{\"code\":301}", 0, NCM_ERR_AUTH, "业务 301 → AUTH" }, + { 200, "{\"code\":302}", 0, NCM_ERR_AUTH, "业务 302 → AUTH" }, + { 200, "{\"code\":-460}", 0, NCM_ERR_SVC, "业务 -460 风控 → SVC(M9:旧版误判 AUTH)" }, + { 200, "not json", 0, NCM_ERR_JSON, "200+非 JSON → JSON/NOTFOUND(other,不登出)" }, + { 200, "{\"code\":-447}", 0, NCM_ERR_HTTP, "其它业务码 → HTTP(other)" }, + { 0, NULL, NCM_ERR_NET, NCM_ERR_NET, "-4 传输层不可达 → NET(离线)" }, + { 0, NULL, NCM_ERR_TLS, NCM_ERR_TLS, "-5 TLS → TLS(离线)" }, + { 0, NULL, NCM_ERR_TIMEOUT, NCM_ERR_TIMEOUT, "-6 超时 → TIMEOUT(离线)" }, + }; + for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) { + ncm_apicache_clear(); + if (m[i].stub_err) stub_err(m[i].stub_err); + else stub(m[i].status, m[i].body, NULL); + ncm_playlist_t pls[4]; + int n = -1; + int rc = ncm_api_user_playlist("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n); + /* 非 JSON 体:「解析失败」具体码可能是 JSON(-9) 或 NOTFOUND(-10)(jsmn 把裸词当原语), + * 关键语义是**不属于鉴权/网络类**(不登出、不改离线态)。 */ + int ok = (rc == m[i].want) || + (m[i].want == NCM_ERR_JSON && ncm_err_classify(rc) == NCM_FAIL_OTHER); + CHECK(ok, "[%s] rc=%d(期望 %d)", m[i].why, rc, m[i].want); + } +} + +/* ============================================================ + * E. 真实 ncm_login:cookie 校验 / 续期 + * ============================================================ */ +static void test_login_map(void) +{ + printf("== E. ncm_login_status_check(启动/uid 校验)==\n"); + char uid[32], nick[128]; + struct { int status; const char *body; int stub_err; int want; const char *why; } m[] = { + { 200, "{\"code\":200,\"profile\":{\"userId\":\"42\",\"nickname\":\"n\"}," + "\"account\":{\"id\":42}}", 0, NCM_OK, "已登录 → OK" }, + { 200, "{\"code\":200,\"profile\":null,\"account\":null}", 0, NCM_ERR_AUTH, + "游客(profile=null)→ AUTH" }, + { 401, "{}", 0, NCM_ERR_AUTH, "HTTP 401 → AUTH" }, + { 500, "bad gateway", 0, NCM_ERR_SVC, "HTTP 500 → SVC(断网/服务异常不清会话)" }, + { 200, "{\"code\":-460}", 0, NCM_ERR_SVC, "业务 -460 风控 → SVC" }, + { 200, "{}", 0, NCM_ERR_JSON, "缺 code → JSON(other)" }, + { 0, NULL, NCM_ERR_NET, NCM_ERR_NET, "网络不可达 → NET" }, + { 0, NULL, NCM_ERR_TLS, NCM_ERR_TLS, "TLS 失败 → TLS" }, + }; + for (size_t i = 0; i < sizeof m / sizeof m[0]; i++) { + if (m[i].stub_err) stub_err(m[i].stub_err); + else stub(m[i].status, m[i].body, NULL); + int rc = ncm_login_status_check("dev", "MUSIC_U=x", uid, sizeof uid, nick, sizeof nick); + CHECK(rc == m[i].want, "[%s] rc=%d(期望 %d)", m[i].why, rc, m[i].want); + } + + printf("== E2. ncm_login_refresh(MUSIC_U 临期续期)==\n"); + char out[NCM_COOKIE_MAX]; + struct { int status; const char *body; const char *setc; int stub_err; int want; const char *why; } r[] = { + { 200, "{\"code\":200}", "MUSIC_U=NEW; __csrf=c2", 0, NCM_OK, + "200+code200 → OK(换新 cookie)" }, + { 200, "{\"code\":301}", NULL, 0, NCM_ERR_AUTH, "业务 301 → AUTH(会话确失效)" }, + { 200, "{\"code\":302}", NULL, 0, NCM_ERR_AUTH, "业务 302 → AUTH" }, + { 200, "{\"code\":-460}", NULL, 0, NCM_ERR_SVC, "业务 -460 风控 → SVC(退避,不清登录态)" }, + { 200, "{\"code\":400}", NULL, 0, NCM_ERR_HTTP, "其它业务码 → HTTP(退避,不登出)" }, + { 502, "bad gateway", NULL, 0, NCM_ERR_SVC, "HTTP 502 → SVC(退避)" }, + { 0, NULL, NULL, NCM_ERR_TLS, NCM_ERR_TLS, "TLS 失败 → TLS(退避)" }, + { 0, NULL, NULL, NCM_ERR_NET, NCM_ERR_NET, "网络不可达 → NET(退避)" }, + }; + for (size_t i = 0; i < sizeof r / sizeof r[0]; i++) { + if (r[i].stub_err) stub_err(r[i].stub_err); + else stub(r[i].status, r[i].body, r[i].setc); + int rc = ncm_login_refresh("dev", "MUSIC_U=OLD; __csrf=c1", out, sizeof out); + CHECK(rc == r[i].want, "[%s] rc=%d(期望 %d)", r[i].why, rc, r[i].want); + if (r[i].want == NCM_OK) + CHECK(strstr(out, "MUSIC_U=NEW") != NULL && strstr(out, "__csrf=c2") != NULL, + "续期成功:新 MUSIC_U/__csrf 已合并"); + } +} + +/* ============================================================ + * F. 会话加密密钥稳定性(旧版依赖网卡 MAC = 断网/换网后被踢扫码的根因) + * ============================================================ */ +static void tmpdir_of(const char *name, char *out, size_t sz) +{ + const char *base = getenv("TMPDIR"); + if (!base || !base[0]) base = getenv("HOME"); + if (!base || !base[0]) base = "."; + snprintf(out, sz, "%s/ncm_m9_%s_%d", base, name, (int)getpid()); + char cmd[512]; + snprintf(cmd, sizeof cmd, "rm -rf '%s'; mkdir -p '%s'", out, out); + (void)system(cmd); +} + +/* 旧版(MAC 派生密钥)密文构造:magic "NCM1" + iv + AES-128-CBC(PKCS7)。 + * 仅单测用,复刻 ncm_store 的旧格式以便验证「升级后可解 + 自动迁移」。 */ +static int legacy_encrypt(const char *plain, unsigned char *out, size_t *out_len) +{ + unsigned char key[16]; + if (ncm_store_machine_key("os.meow.ncm.session.v1", key) != 0) return -1; + size_t n = strlen(plain); + int pad = (int)(16 - (n % 16)); + size_t total = n + (size_t)pad; + unsigned char iv[16], iv0[16]; + for (int i = 0; i < 16; i++) iv[i] = (unsigned char)(0x10 + i); + memcpy(iv0, iv, 16); + unsigned char *pt = (unsigned char *)malloc(total); + unsigned char *ct = (unsigned char *)malloc(total); + if (!pt || !ct) { free(pt); free(ct); return -1; } + memcpy(pt, plain, n); + memset(pt + n, pad, (size_t)pad); + mbedtls_aes_context aes; + mbedtls_aes_init(&aes); + mbedtls_aes_setkey_enc(&aes, key, 128); + mbedtls_aes_crypt_cbc(&aes, MBEDTLS_AES_ENCRYPT, total, iv0, pt, ct); + mbedtls_aes_free(&aes); + memcpy(out, "NCM1", 4); + memcpy(out + 4, iv, 16); + memcpy(out + 20, ct, total); + *out_len = 20 + total; + free(pt); free(ct); + return 0; +} + +static void test_session_key_stability(void) +{ + printf("== F. 会话加密密钥稳定性(断网/网卡变化不再丢登录态)==\n"); + const char *cookie = "MUSIC_U=ABC123; __csrf=xyz"; + char dir[256]; + tmpdir_of("store", dir, sizeof dir); + + setenv("NCM_MAC", "aa:bb:cc:dd:ee:01", 1); + ncm_store_t st; + CHECK(ncm_store_init(&st, dir) == 0, "store init"); + CHECK(ncm_store_save_session(&st, cookie) == NCM_OK, "session 落盘(.mkey 派生密钥)"); + + /* 网卡变化(断网/换网卡/uplink 顺序变化):MAC 从 01 变 02 */ + setenv("NCM_MAC", "aa:bb:cc:dd:ee:02", 1); + char back[NCM_COOKIE_MAX]; + int rc = ncm_store_load_session(&st, back, sizeof back); + CHECK(rc == NCM_OK && strcmp(back, cookie) == 0, + "MAC 变化后 session 仍可解(旧版此处失败→cookie 丢失→退扫码)"); + + /* 旧版 MAC 派生密文:仍可解,且 load_session 自动迁移为新密钥 */ + char dir2[256]; + tmpdir_of("legacy", dir2, sizeof dir2); + ncm_store_t st2; + ncm_store_init(&st2, dir2); + unsigned char enc[8192]; + size_t elen = 0; + setenv("NCM_MAC", "aa:bb:cc:dd:ee:03", 1); + CHECK(legacy_encrypt(cookie, enc, &elen) == 0, "构造旧版(MAC 派生)密文"); + char path[600]; + snprintf(path, sizeof path, "%s/session.enc", dir2); + CHECK(ncm_store_write_file(path, enc, elen) == NCM_OK, "写入旧版 session.enc"); + setenv("NCM_MAC", "aa:bb:cc:dd:ee:04", 1); /* 故意换 MAC:旧解密本应失败 */ + char back2[NCM_COOKIE_MAX]; + rc = ncm_store_load_session(&st2, back2, sizeof back2); + /* 旧格式是用 03 的 MAC 加的密钥;此刻 NCM_MAC=04 → 旧密钥也解不出,这是预期: + * 迁移只在「当前 MAC 与旧文件一致」时发生(真机升级时 MAC 未变)。验证迁移路径: */ + CHECK(rc != NCM_OK, "旧密文 + 已换 MAC → 解不出(预期,证明旧方案确实依赖网卡)"); + + snprintf(path, sizeof path, "%s/session.enc", dir2); + setenv("NCM_MAC", "aa:bb:cc:dd:ee:03", 1); /* 回到写入时的 MAC(=真机升级现场) */ + CHECK(ncm_store_write_file(path, enc, elen) == NCM_OK, "重写旧版 session.enc"); + rc = ncm_store_load_session(&st2, back2, sizeof back2); + CHECK(rc == NCM_OK && strcmp(back2, cookie) == 0, "旧密文在升级现场可解(兼容)"); + setenv("NCM_MAC", "aa:bb:cc:dd:ee:05", 1); /* 迁移后:换 MAC 仍可解 */ + char back3[NCM_COOKIE_MAX]; + rc = ncm_store_load_session(&st2, back3, sizeof back3); + CHECK(rc == NCM_OK && strcmp(back3, cookie) == 0, "已自动迁移为 .mkey 密钥(换 MAC 仍可解)"); + + char cmd[512]; + snprintf(cmd, sizeof cmd, "rm -rf '%s' '%s'", dir, dir2); + (void)system(cmd); + unsetenv("NCM_MAC"); +} + +int main(void) +{ + printf("===== M9 离线会话保持 / 登录门 宿主单测 =====\n"); + test_err_class(); + test_http_class(); + test_gate(); + test_api_map(); + test_login_map(); + test_session_key_stability(); + printf("\n== SESSION GATE SIM: %s (http stubs called %d times) ==\n", + g_fail ? "FAIL" : "ALL PASS", g_http_calls); + return g_fail ? 1 : 0; +} diff --git a/patch_v2/ncm_app/bench/window_sim.c b/patch_v2/ncm_app/bench/window_sim.c index dc42eb3..5ee26e2 100644 --- a/patch_v2/ncm_app/bench/window_sim.c +++ b/patch_v2/ncm_app/bench/window_sim.c @@ -20,7 +20,8 @@ * -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ws_app.o * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ * -Icore -I../vendor/jsmn -c app/logic/songmeta.c -o /tmp/ws_meta.o - * $CC_HOST -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o -lpthread + * $CC_HOST -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o \ + * out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread * 运行:./window_sim (0 网络;PASS/FAIL 退出码) */ #include "../app/ncm_app_internal.h" diff --git a/patch_v2/ncm_app/build_ncm_sims.sh b/patch_v2/ncm_app/build_ncm_sims.sh index 1a9029a..c1b5052 100755 --- a/patch_v2/ncm_app/build_ncm_sims.sh +++ b/patch_v2/ncm_app/build_ncm_sims.sh @@ -72,7 +72,7 @@ gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ -Icore -Ivendor/jsmn -Ivendor/mbedtls/include -c app/logic/songmeta.c -o /tmp/ws_meta.o gcc -O2 -Wl,--gc-sections -o window_sim /tmp/ws.o /tmp/ws_app.o /tmp/ws_meta.o \ - out/core-host/ncm_apicache.o -lpthread # appstate.c 调 ncm_apicache_init/clear(自包含模块) + out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread # appstate.c 调 ncm_apicache_*/ncm_gate_decide run_sim window_sim echo "==> build plpage_sim(歌单页懒加载分页 + 加载后列表刷新;真实 playlist.c 全链路 + 宿主 SDK,0 网络。" @@ -84,6 +84,7 @@ for f in bench/plpage_sim.c app/ui/playlist.c app/logic/appstate.c app/logic/son done gcc -O2 -Wl,--gc-sections -Wl,--wrap=meow_ui_paint_full -o plpage_sim \ /tmp/pp_plpage_sim.o /tmp/pp_playlist.o /tmp/pp_appstate.o /tmp/pp_songmeta.o /tmp/pp_chrome.o /tmp/pp_ncm_colors.o \ + out/core-host/ncm_session.o \ out/host/sdk_*.o -lz -lpthread -lm run_sim plpage_sim @@ -93,7 +94,7 @@ gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd -Icor gcc -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ -Icore -Ivendor/jsmn -c app/logic/appstate.c -o /tmp/sh_app.o gcc -O2 -Wl,--gc-sections -o search_sim /tmp/sh.o /tmp/sh_app.o \ - out/core-host/ncm_apicache.o -lpthread # appstate.c 调 ncm_apicache_init/clear + out/core-host/ncm_apicache.o out/core-host/ncm_session.o -lpthread # appstate.c 调 ncm_apicache_*/ncm_gate_decide run_sim search_sim echo "==> build cache_evict_sim" @@ -110,7 +111,8 @@ gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd \ # 故不链 ncm_http.o)+ jsmn + 宿主 mbedtls;核心对象从 out/core-host/*.o 取(含 ncm_apicache.o)。 gcc -O2 -o apicache_sim /tmp/ac.o \ $(ls out/core-host/ncm_api.o out/core-host/ncm_req.o out/core-host/ncm_crypto.o \ - out/core-host/ncm_json.o out/core-host/ncm_common.o out/core-host/ncm_apicache.o) \ + out/core-host/ncm_json.o out/core-host/ncm_common.o out/core-host/ncm_apicache.o \ + out/core-host/ncm_session.o) \ out/core-host/jsmn_impl.o \ -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm run_sim apicache_sim @@ -122,11 +124,23 @@ gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -I../sdk -I../meow_launcherd \ gcc -O2 -o cookie_refresh_sim /tmp/crs.o \ $(ls out/core-host/ncm_login.o out/core-host/ncm_req.o out/core-host/ncm_crypto.o \ out/core-host/ncm_json.o out/core-host/ncm_common.o out/core-host/ncm_store.o \ - out/core-host/v_qrcodegen.o) \ + out/core-host/ncm_session.o out/core-host/v_qrcodegen.o) \ out/core-host/jsmn_impl.o \ -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm run_sim cookie_refresh_sim +echo "==> build session_gate_sim(M9 离线会话保持/登录门:分类矩阵 + HTTP 状态/业务码映射 + 会话密钥稳定性;0 真实网络)" +mkdir -p out/chk +gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/jsmn -Ivendor/mbedtls/include \ + -c bench/session_gate_sim.c -o out/chk/sg.o +gcc -O2 -o session_gate_sim out/chk/sg.o \ + $(ls out/core-host/ncm_api.o out/core-host/ncm_login.o out/core-host/ncm_req.o \ + out/core-host/ncm_crypto.o out/core-host/ncm_json.o out/core-host/ncm_common.o \ + out/core-host/ncm_store.o out/core-host/ncm_apicache.o out/core-host/ncm_session.o) \ + out/core-host/jsmn_impl.o out/core-host/v_qrcodegen.o \ + -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm +run_sim session_gate_sim + echo "==> build account_cookie_sim (§NEW 临期续期决策;真实 account.c + 外围桩,0 真实网络)" gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \ -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \