Merge branch 'feat/ncm-offline-session-keep'

This commit is contained in:
AskaEth committed 2026-11-05 12:01:33 +08:00
commit 61ef0f6de9
31 files changed
+1158 -62

No files matched your search

+111
View File
@@ -3480,3 +3480,114 @@ MP3 ≤128k→标准,>128k→极高 HQ。列表显示**实际档**(如 hires
- **live 网络测试跳过**:Termux 环境 HTTPS 到 music.163.com 返回 rc=-5(网络/证书被拒),2 项 SKIP;加密/缓存/API 解析等离线项全部 PASS。 - **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` 二进制、`meow_ui.a`、vendor、`out/` 均只在本机产出;仓库中原本被跟踪的旧 `ncm`/`test_ncm_core_host` 已恢复为 HEAD 版本,避免把本机构建产物带进提交。
- 卡上保留两份备份(`ncm.bak` 与 `ncm.bak.<旧md5>`),需要回滚可 `cp` 任一覆盖。 - 卡上保留两份备份(`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 行)。**已是 NCM 错误码的一律原样返回**(含 `NCM_ERR_JSON`:畸形分块/帧体是数据错误,不得改判成网络不可达——评审 P2-1)。 |
| `core/ncm_api.c` | `api_status_gate`:非 2xx 先按状态定性(401/403→AUTH、5xx/429→SVC、其它→HTTP),不再把 5xx HTML 丢给 JSON 解析;业务码映射加 302→AUTH、`-460`→SVC(113-141 行)。`api_post_cached` 新增 `from_cache` 出参;新增 `ncm_api_user_playlist_ex`(回填「本次是否接口缓存命中」;`out_n` 与原接口一致必填、`out_from_cache` 可 NULL),旧接口为薄包装(评审 P2-2)。 |
| `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` | 会话密钥改为设备私有 `<data_dir>/.mkey` 派生(`key_from_mkey`/`session_key`,175-193 行);旧 MAC 派生密文仍可解(`cookie_decrypt_sel`,270 行)且 `load_session` 命中旧密钥时自动重写迁移(306-317 行)。 |
| `core/ncm_apicache.c/.h` | 盘缓存命中分支的两行合并为一行(行为不变,重构)。**不再提供全局命中计数**(评审 P2-2:跨 worker 差值会误判、自增在锁外有竞争;改为单请求出参,见下)。 |
| `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 清零离线态;新增 `appstate_load_playlists_ex(from_cache)` 透传单请求缓存命中标志。 |
| `app/app.c` | 启动根页改由 `ncm_session_boot_decide` 决定并打日志(245-252 行);`need_relogin` 路径保持(仅 AUTH 会置位)。 |
| `app/ui/home.c` | worker 结果经 `appstate_session_gate`;「本次是否接口缓存命中」用 `appstate_load_playlists_ex(&from_cache)` 的单请求出参判定(缓存命中→不判回在线),个人卡右侧「离线模式」红标 + 页脚提示 + 空态「离线模式/可播放已缓存」;离线时每 30s 静默重试一次(强制绕过接口缓存)。 |
| `app/ui/playlist.c` | 首屏**失败**时同步离线态(成功不判回在线:歌单详情可能命中接口缓存;锁外应用);空态「离线模式」。 |
| `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` 桩) | **88 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、传输码)、D2 `from_cache` 出参 5 例(miss→0/命中→1 且 0 请求/失败→0/兼容入口/out_n 必填契约)、E `login_status` 8 + `login_refresh` 8 例、F 会话密钥 9 例(换 MAC 仍可解、旧密文可解并自动迁移) |
| `bench/http_truncate_sim` | **13 PASS**(原 8 截断用例零回归 + 4 个传输层归一用例 + 1 个畸形分块(NCM_ERR_JSON 不被改判 NET)用例) |
| `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_api_user_playlist_ex` 的 `from_cache` 出参判定本次
结果是否真走网络(评审 P2-2:不用全局计数差值);离线恢复探测(home 每 30s)强制
`ncm_api_cache_refresh_path` 绕过缓存,确保成功即真在线。歌单页的缓存命中成功同样不判回在线
(该路径只在失败时更新离线态,在线恢复由 home 探测统一裁决)。
- **畸形分块/帧体归 `NCM_ERR_JSON`(数据错误)**,`http_ret_norm` 不再把它改判为网络不可达
(评审 P2-1:否则会误报离线)。
- **离线播放未缓存曲目**仍走原 `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**:设备离线,真机联调后置;本节所有验证均在宿主完成。
+9 -2
View File
@@ -242,8 +242,15 @@ int main(void)
g_cache_view = cache_view_create(); /* M5 已缓存列表页 */ g_cache_view = cache_view_create(); /* M5 已缓存列表页 */
mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */ mini_bar_init(); /* mini 播放栏:登记跨 Activity 绘制物(进 root 页前,首帧即参与重画) */
/* 进入根页:已登录→home,否则→login */ /* 进入根页(M9 启动决策):本地有会话就先信它——无网也进 home(缓存可看可播),
meow_view_page_push(g_st.logged_in ? ACT_HOME : ACT_LOGIN); * 只有「无本地会话」才进扫码页。网络类失败在首个请求后由 appstate_session_gate
* 切离线模式,不再把「连不上」当登录过期。 */
{
ncm_gate_t boot = ncm_session_boot_decide(g_st.logged_in, NCM_OK);
meow_log(MEOW_LOG_INFO, "boot: session gate=%s (cookie=%d uid=%s)",
ncm_gate_name(boot), g_st.logged_in, g_st.uid[0] ? g_st.uid : "-");
meow_view_page_push(boot == NCM_GATE_RELOGIN ? ACT_LOGIN : ACT_HOME);
}
meow_ui_events ev = { meow_ui_events ev = {
app_on_key, app_on_tap, app_on_swipe, app_on_tick, app_on_swipe_progress, app_on_key, app_on_tap, app_on_swipe, app_on_tick, app_on_swipe_progress,
+49 -7
View File
@@ -98,6 +98,7 @@ int appstate_init(void)
appstate_load_settings(); appstate_load_settings();
g_st.logged_in = (g_st.ctx.cookie[0] != 0); g_st.logged_in = (g_st.ctx.cookie[0] != 0);
g_st.offline = 0; /* M9:启动先按在线渲染;首个网络动作失败再进离线(本地会话不被否决) */
g_st.active_playlist = -1; g_st.active_playlist = -1;
g_st.playing_song = -1; g_st.playing_song = -1;
g_st.qr_size = 0; g_st.qr_size = 0;
@@ -169,6 +170,33 @@ void appstate_set_logged_in(const char *uid, const char *nickname)
g_st.logged_in = 1; g_st.logged_in = 1;
} }
/* ---- 会话保持 / 离线模式(M9)----
* 判定本身在 core(ncm_gate_decide,宿主可单测);这里只把结果落到 App 状态并打日志。
* 关键不变量:**只有 NCM_ERR_AUTH 会走退出登录**;传输层/服务端暂不可用只切离线,
* 绝不清 cookie(清 cookie 才是「退到扫码登录」的实质)。 */
void appstate_offline_set(int on)
{
int v = on ? 1 : 0;
if (g_st.offline == v) return;
g_st.offline = v;
meow_log(v ? MEOW_LOG_WARN : MEOW_LOG_INFO, "session: %s", v ? "offline" : "online");
}
int appstate_is_offline(void) { return g_st.offline; }
int appstate_session_gate(int rc)
{
ncm_gate_t g = ncm_gate_decide(g_st.logged_in, rc);
switch (g) {
case NCM_GATE_ONLINE: appstate_offline_set(0); break;
case NCM_GATE_OFFLINE: appstate_offline_set(1); break;
/* RELOGIN:登录态处置由调用方(app.c tick / settings 登出)执行;
* HOLD:解析/参数类错误,保持现状。 */
default: break;
}
return (int)g;
}
void appstate_logout(void) void appstate_logout(void)
{ {
ncm_ctx_clear_cookie(&g_st.ctx); ncm_ctx_clear_cookie(&g_st.ctx);
@@ -176,6 +204,7 @@ void appstate_logout(void)
* 被下一账号复用;盘缓存目录一并清)。 */ * 被下一账号复用;盘缓存目录一并清)。 */
ncm_apicache_clear(); ncm_apicache_clear();
g_st.logged_in = 0; g_st.logged_in = 0;
g_st.offline = 0;
g_st.uid[0] = 0; g_st.uid[0] = 0;
g_st.nickname[0] = 0; g_st.nickname[0] = 0;
g_st.vip = NCM_VIP_NONE; /* §20:登出清会员态(下一账号不继承) */ g_st.vip = NCM_VIP_NONE; /* §20:登出清会员态(下一账号不继承) */
@@ -199,7 +228,9 @@ void appstate_logout(void)
cover_cache_reset(); cover_cache_reset();
} }
/* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。 */ /* 若 uid 缺失(cookie 在但没存 uid),用 login_status_check 校验并取回。返回 0=成功。
* M9:只有服务端**明确**否定会话(NCM_ERR_AUTH:HTTP 401/403、业务码 301/302)才清 cookie;
* 网络不可达/超时/服务端 5xx 一律保留 cookie 与登录态,只切离线模式。 */
int appstate_ensure_uid(void) int appstate_ensure_uid(void)
{ {
if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_ERR_AUTH; if (!g_st.logged_in || !g_st.ctx.cookie[0]) return NCM_ERR_AUTH;
@@ -209,12 +240,16 @@ int appstate_ensure_uid(void)
nick, sizeof nick); nick, sizeof nick);
if (rc == NCM_OK) { if (rc == NCM_OK) {
appstate_set_logged_in(uid, nick); appstate_set_logged_in(uid, nick);
appstate_offline_set(0);
return NCM_OK; return NCM_OK;
} }
/* 会话失效:清 cookie → 未登录 */
if (rc == NCM_ERR_AUTH) { if (rc == NCM_ERR_AUTH) {
/* 会话确失效:清 cookie → 未登录(原逻辑) */
ncm_ctx_clear_cookie(&g_st.ctx); ncm_ctx_clear_cookie(&g_st.ctx);
g_st.logged_in = 0; g_st.logged_in = 0;
g_st.offline = 0;
} else if (ncm_err_is_network(rc)) {
appstate_offline_set(1); /* 只是连不上:保留账号态,离线可看缓存 */
} }
return rc; return rc;
} }
@@ -224,8 +259,11 @@ int appstate_ensure_uid(void)
* 之间的发布点。持久化(playlists.bin)同单写者(home worker)在发布后读,无相互竞态。 */ * 之间的发布点。持久化(playlists.bin)同单写者(home worker)在发布后读,无相互竞态。 */
static pthread_mutex_t g_pls_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_pls_mu = PTHREAD_MUTEX_INITIALIZER;
int appstate_load_playlists(void) /* from_cache(可 NULL):透传 ncm_api_user_playlist_ex 的「本次是否接口缓存命中」。
* 仅真正走网络成功时才应把 App 判为在线(M9 评审 P2-2:缓存命中不代表网络恢复)。 */
int appstate_load_playlists_ex(int *from_cache)
{ {
if (from_cache) *from_cache = 0;
if (!g_st.logged_in) return NCM_ERR_AUTH; if (!g_st.logged_in) return NCM_ERR_AUTH;
int rc = appstate_ensure_uid(); int rc = appstate_ensure_uid();
if (rc != NCM_OK) return rc; if (rc != NCM_OK) return rc;
@@ -238,8 +276,9 @@ int appstate_load_playlists(void)
* 单飞),免 96KB 栈。 */ * 单飞),免 96KB 栈。 */
static ncm_playlist_t s_pls[NCM_PLAYLIST_MAX]; static ncm_playlist_t s_pls[NCM_PLAYLIST_MAX];
int n = 0; int n = 0;
int rc2 = ncm_api_user_playlist(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid, int rc2 = ncm_api_user_playlist_ex(g_st.ctx.device_id, g_st.ctx.cookie, g_st.uid,
NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n); NCM_PLAYLIST_MAX, 0, s_pls, NCM_PLAYLIST_MAX, &n,
from_cache);
if (rc2 == NCM_OK) { if (rc2 == NCM_OK) {
int cnt = (n > NCM_PLAYLIST_MAX) ? NCM_PLAYLIST_MAX : n; int cnt = (n > NCM_PLAYLIST_MAX) ? NCM_PLAYLIST_MAX : n;
pthread_mutex_lock(&g_pls_mu); pthread_mutex_lock(&g_pls_mu);
@@ -248,11 +287,14 @@ int appstate_load_playlists(void)
pthread_mutex_unlock(&g_pls_mu); pthread_mutex_unlock(&g_pls_mu);
appstate_playlists_cache_save(); /* §26:网络成功 → 覆写盘缓存(stale-while-revalidate) */ appstate_playlists_cache_save(); /* §26:网络成功 → 覆写盘缓存(stale-while-revalidate) */
} }
meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s", rc2, meow_log(MEOW_LOG_INFO, "home: playlists rc=%d count=%d uid=%s%s", rc2,
(rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid); (rc2 == NCM_OK) ? n : g_st.playlist_count, g_st.uid,
(from_cache && *from_cache) ? " (cache)" : "");
return rc2; return rc2;
} }
int appstate_load_playlists(void) { return appstate_load_playlists_ex(NULL); }
/* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)---- /* ---- 歌单列表磁盘缓存(§26,NCM_CACHE_DESIGN §2.2)----
* 快照 <data_dir>/playlists.bin:magic(4) + ver(2) + 时间戳(8) + count(4) + 数组(ncm_playlist_t)。 * 快照 <data_dir>/playlists.bin:magic(4) + ver(2) + 时间戳(8) + count(4) + 数组(ncm_playlist_t)。
* 进首页先渲染缓存秒开(断网可离线浏览),后台 worker 拉网络成功后覆写;结构体改版靠 ver 判 miss。 * 进首页先渲染缓存秒开(断网可离线浏览),后台 worker 拉网络成功后覆写;结构体改版靠 ver 判 miss。
+17
View File
@@ -205,6 +205,10 @@ typedef struct ncm_app_state {
int qr_size; int qr_size;
int login_state; /* NCM_LOGIN_* */ int login_state; /* NCM_LOGIN_* */
int logged_in; int logged_in;
/* 离线模式(M9):网络类失败(不可达/超时/DNS/连接失败/服务端 5xx)时为 1——账号态与
* cookie 一律保留,UI 显示可见提示、已缓存列表照常可开可播;任何一次请求成功即回 0。
* 鉴权失败(NCM_ERR_AUTH)不走这里:沿用原退出登录→扫码逻辑。 */
int offline;
/* 账号 */ /* 账号 */
char uid[32]; char uid[32];
@@ -433,7 +437,20 @@ void appstate_save_settings(void);
void appstate_set_logged_in(const char *uid, const char *nickname); void appstate_set_logged_in(const char *uid, const char *nickname);
void appstate_logout(void); void appstate_logout(void);
int appstate_ensure_uid(void); /* 若 uid 缺失则 login_status_check 取回;0=成功 */ int appstate_ensure_uid(void); /* 若 uid 缺失则 login_status_check 取回;0=成功 */
/* ---- 会话保持 / 离线模式(M9)----
* 一次网络动作的返回码 → 会话状态机(core ncm_session.h 的纯判定在上,这里落到 App 状态):
* 成功 → 在线(解除离线);网络类失败 → 离线(保留登录态/cookie);
* 鉴权失败 → 由调用方沿用原退出登录逻辑(本函数不改登录态);其它 → 保持现状。
* 返回 ncm_gate_t。可在 worker 线程调用(只写 g_st.offline,值域 0/1 幂等)。 */
int appstate_session_gate(int rc);
int appstate_is_offline(void); /* 1=离线模式(UI 画「离线模式」提示) */
void appstate_offline_set(int on); /* 直接置离线态(登出/初始化清零) */
int appstate_load_playlists(void); /* 拉我的歌单 */ int appstate_load_playlists(void); /* 拉我的歌单 */
int appstate_load_playlists_ex(int *from_cache);
/* 同上,额外回填「本次是否接口缓存命中」:
* 1=缓存(未走网络,不能判为已回在线);0=走网络/失败。
* from_cache 可为 NULL(等价 appstate_load_playlists)。 */
int appstate_load_playlist_songs(int idx); /* 兼容:载索引 + 首个窗口(内部走 ensure) */ int appstate_load_playlist_songs(int idx); /* 兼容:载索引 + 首个窗口(内部走 ensure) */
int appstate_load_playlist_songs_page(int idx, int offset, int limit); int appstate_load_playlist_songs_page(int idx, int offset, int limit);
/* 兼容入口(滑动窗口语义,REPORT §17): /* 兼容入口(滑动窗口语义,REPORT §17):
+3 -2
View File
@@ -161,9 +161,10 @@ static void c_draw_footer(void *ctx)
ncm_cache_item_t it; ncm_cache_item_t it;
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
if (appstate_cache_item(i, &it) == NCM_OK) total += it.size; if (appstate_cache_item(i, &it) == NCM_OK) total += it.size;
char sz[24], foot[96]; char sz[24], foot[128];
fmt_size(total, sz, sizeof sz); fmt_size(total, sz, sizeof sz);
snprintf(foot, sizeof foot, "%d 首 · 合计 %s", n, sz); snprintf(foot, sizeof foot, "%d 首 · 合计 %s%s", n, sz,
appstate_is_offline() ? " · 离线模式(可播放)" : "");
ncm_draw_footer(r_cont, foot); ncm_draw_footer(r_cont, foot);
} }
+40 -4
View File
@@ -146,6 +146,21 @@ static void home_draw(void *ctx)
} }
meow_ui_text_right(pc.x + pc.w - 16, pc.y + (pc.h - 28) / 2, ">", 28, th->onSurfaceVariant); meow_ui_text_right(pc.x + pc.w - 16, pc.y + (pc.h - 28) / 2, ">", 28, th->onSurfaceVariant);
/* M9 离线模式:个人卡右侧可见提示(红点 + 红字,避开「>」)。断网不再退扫码,
* 用户能从这一处看出「当前是离线,缓存照常可播」。 */
if (appstate_is_offline()) {
const char *ol = "离线模式";
int osz = th->font_body - 2;
if (osz < 16) osz = 16;
int ow = meow_ui_text_width(ol, osz);
int ox = pc.x + pc.w - 28 - ow;
int oy = pc.y + (pc.h - osz) / 2;
if (ox > pc.x + asz + 28) {
meow_ui_fill_rect(ox - 14, oy + osz / 2 - 3, 6, 6, pa->red);
meow_ui_text(ox, oy, ol, osz, pa->red);
}
}
/* 双页签(g_tabs=meow_tabbar widget 建)——红下划线/红选中字叠层。 */ /* 双页签(g_tabs=meow_tabbar widget 建)——红下划线/红选中字叠层。 */
if (g_tabs) { if (g_tabs) {
int iw = r_tabs.w / 2; int iw = r_tabs.w / 2;
@@ -201,6 +216,8 @@ static void home_draw_empty(void *ctx)
if (g_home_loading) { ncm_draw_empty(r_list, "LOADING", "正在加载歌单..."); return; } if (g_home_loading) { ncm_draw_empty(r_list, "LOADING", "正在加载歌单..."); return; }
if (g_st.logged_in == 0) ncm_draw_empty(r_list, "NOT SIGNED IN", "请先登录"); if (g_st.logged_in == 0) ncm_draw_empty(r_list, "NOT SIGNED IN", "请先登录");
else if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "SESSION EXPIRED", "请重新登录"); else if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "SESSION EXPIRED", "请重新登录");
/* M9 离线模式:网络类失败不再是「登录过期」,提示可离线听已缓存。 */
else if (appstate_is_offline()) ncm_draw_empty(r_list, "离线模式", "网络不可用 · 可播放已缓存(顶栏「已缓存」)");
else if (g_load_err) ncm_draw_empty(r_list, "NETWORK ERROR", "上滑重试"); else if (g_load_err) ncm_draw_empty(r_list, "NETWORK ERROR", "上滑重试");
else ncm_draw_empty(r_list, "NO PLAYLISTS", "还没有歌单"); else ncm_draw_empty(r_list, "NO PLAYLISTS", "还没有歌单");
} }
@@ -211,8 +228,9 @@ static void home_draw_footer(void *ctx)
if (meow_ui_activity_active() != ACT_HOME) return; if (meow_ui_activity_active() != ACT_HOME) return;
if (g_load_err != 0) return; if (g_load_err != 0) return;
char foot[192]; char foot[192];
snprintf(foot, sizeof foot, "共 %d 个歌单 %s", g_st.playlist_count, snprintf(foot, sizeof foot, "共 %d 个歌单 %s%s", g_st.playlist_count,
g_st.nickname[0] ? g_st.nickname : ""); g_st.nickname[0] ? g_st.nickname : "",
appstate_is_offline() ? " · 离线模式" : "");
ncm_draw_footer(r_cont, foot); ncm_draw_footer(r_cont, foot);
} }
@@ -237,16 +255,21 @@ static void *home_worker(void *arg)
{ {
(void)arg; (void)arg;
int rc; int rc;
int from_cache = 0;
if (!g_st.logged_in) rc = NCM_ERR_AUTH; if (!g_st.logged_in) rc = NCM_ERR_AUTH;
else { else {
/* §NEW MUSIC_U 临期续期:先于歌单网络请求(后续 eapi/weapi 用新 cookie)。 /* §NEW MUSIC_U 临期续期:先于歌单网络请求(后续 eapi/weapi 用新 cookie)。
* AUTH=会话确失效(置回登录页标志,UI 线程消费);瞬断=保持旧 cookie 走缓存/旧会话。 */ * AUTH=会话确失效(置回登录页标志,UI 线程消费);瞬断=保持旧 cookie 走缓存/旧会话。 */
int crc = account_cookie_maybe_refresh(); int crc = account_cookie_maybe_refresh();
if (crc == NCM_ERR_AUTH) rc = NCM_ERR_AUTH; if (crc == NCM_ERR_AUTH) rc = NCM_ERR_AUTH;
else rc = appstate_load_playlists(); else rc = appstate_load_playlists_ex(&from_cache);
} }
if (rc == NCM_OK) if (rc == NCM_OK)
account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */ account_vip_maybe_refresh(0); /* §20:超 1 天/到期后各补查一次(worker 线程,不卡 UI) */
/* M9:网络类失败→离线(保留登录态);成功→回在线。但「接口缓存命中」不算回在线——
* 离线时读到 120s 内的旧缓存同样返回 NCM_OK,不能据此清掉离线提示/重试节奏。
* 判定用本次请求的出参(评审 P2-2:不再用全局命中计数做前后差值)。 */
if (!(rc == NCM_OK && from_cache)) appstate_session_gate(rc);
pthread_mutex_lock(&g_home_mu); pthread_mutex_lock(&g_home_mu);
g_load_err = (g_st.playlist_count > 0) ? 0 : rc; /* 有数据(缓存/已载)即不置错误态 */ g_load_err = (g_st.playlist_count > 0) ? 0 : rc; /* 有数据(缓存/已载)即不置错误态 */
g_home_loading = 0; g_home_loading = 0;
@@ -276,7 +299,20 @@ void home_reload(void)
void home_tick(uint64_t now) void home_tick(uint64_t now)
{ {
(void)now; /* M9 离线恢复:离线时每 30s 静默重试一次(home worker 单飞;成功后 appstate_session_gate
* 自动回在线并重绘)。仅在 home 可见时轮询,不轰炸服务端(风控纪律)。 */
static uint64_t g_offline_retry_ms;
if (appstate_is_offline() && !g_host_mock) {
if (!g_home_thr_on && (!g_offline_retry_ms || now - g_offline_retry_ms >= 30000)) {
g_offline_retry_ms = now;
meow_log(MEOW_LOG_INFO, "home: offline retry");
ncm_api_cache_refresh_path("/api/user/playlist"); /* 离线恢复探测必须真打网络 */
home_reload();
}
} else {
g_offline_retry_ms = 0;
}
if (!g_home_thr_on) return; if (!g_home_thr_on) return;
if (g_home_gen == g_home_acked) return; if (g_home_gen == g_home_acked) return;
g_home_acked = g_home_gen; g_home_acked = g_home_gen;
+4 -1
View File
@@ -422,10 +422,13 @@ static void plr_draw_cover_dyn(void *ctx)
/* 缓冲/错误态(沿用 §18 三态;错误时用文案替代时间行) */ /* 缓冲/错误态(沿用 §18 三态;错误时用文案替代时间行) */
if (perr != 0) { if (perr != 0) {
/* M9:网络类失败(不可达/超时/5xx)提示离线可播缓存,而不是「登录过期」。 */
int pnet = ncm_err_is_network(perr);
const char *em = (perr == NCM_ERR_VIP) ? "VIP 专享,开通会员后可播放" const char *em = (perr == NCM_ERR_VIP) ? "VIP 专享,开通会员后可播放"
: (perr == NCM_ERR_NOTFOUND) ? "无法播放:无音源(VIP/版权)" : (perr == NCM_ERR_NOTFOUND) ? "无法播放:无音源(VIP/版权)"
: (perr == NCM_ERR_AUTH) ? "登录已过期,请重新登录" : (perr == NCM_ERR_AUTH) ? "登录已过期,请重新登录"
: "加载失败,请检查网络"; : pnet ? "离线模式:网络不可用,可播放已缓存"
: "加载失败,请重试";
meow_ui_text_center(W / 2, g_times_y - 8, em, 20, pa->red_lit); meow_ui_text_center(W / 2, g_times_y - 8, em, 20, pa->red_lit);
meow_ui_text_center(W / 2, g_times_y + 18, "点播放键重试 / 切下一首", 18, PLR_TEXT_DIM); meow_ui_text_center(W / 2, g_times_y + 18, "点播放键重试 / 切下一首", 18, PLR_TEXT_DIM);
} else { } else {
+6
View File
@@ -225,6 +225,7 @@ static void pl_draw_empty(void *ctx)
if (g_load_err == 0 && g_st.track_ids_count != 0) return; if (g_load_err == 0 && g_st.track_ids_count != 0) return;
if (g_plloading) { ncm_draw_empty(r_list, "加载中", "正在加载曲目..."); return; } if (g_plloading) { ncm_draw_empty(r_list, "加载中", "正在加载曲目..."); return; }
if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录"); if (g_load_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录");
else if (appstate_is_offline()) ncm_draw_empty(r_list, "离线模式", "网络不可用 · 可播放已缓存(返回首页→「已缓存」)");
else if (g_load_err) ncm_draw_empty(r_list, "加载失败", "点按重试"); else if (g_load_err) ncm_draw_empty(r_list, "加载失败", "点按重试");
else ncm_draw_empty(r_list, "暂无歌曲", "这个歌单是空的"); else ncm_draw_empty(r_list, "暂无歌曲", "这个歌单是空的");
} }
@@ -278,6 +279,7 @@ static void *playlist_worker(void *arg)
pthread_mutex_unlock(&g_plmu); pthread_mutex_unlock(&g_plmu);
int cur_active = g_st.active_playlist; /* ensure 路径结束校验用(当前目标) */ int cur_active = g_st.active_playlist; /* ensure 路径结束校验用(当前目标) */
int rc; int rc;
int gate_rc = NCM_ERR_STALE; /* M9:首屏结果→会话/离线态(锁外应用) */
if (g_host_mock) rc = 0; if (g_host_mock) rc = 0;
else if (first) { else if (first) {
/* 首屏:快照式加载(detail 全量 trackIds + 物化第 0 页)。ctx 校验代次/目标,过期自动丢弃。 /* 首屏:快照式加载(detail 全量 trackIds + 物化第 0 页)。ctx 校验代次/目标,过期自动丢弃。
@@ -312,6 +314,9 @@ static void *playlist_worker(void *arg)
} else { } else {
g_load_err = rc; /* 索引失败/鉴权失败 → 整页空态(点按重试) */ g_load_err = rc; /* 索引失败/鉴权失败 → 整页空态(点按重试) */
} }
/* M9:本次首屏失败(非 STALE)同步离线态;成功不在此判「回在线」——歌单详情可能命中
* 接口缓存,缓存命中不代表网络恢复(回在线由 home 的离线探测/真实成功判定)。 */
gate_rc = rc;
g_plloaded_idx = tidx; g_plloaded_idx = tidx;
g_plloaded_gen = tgen; g_plloaded_gen = tgen;
/* §NEW bug B:首屏发布后标记窗口归属=目标歌单(失败/部分也归属,支持同歌单重试不清窗)。 /* §NEW bug B:首屏发布后标记窗口归属=目标歌单(失败/部分也归属,支持同歌单重试不清窗)。
@@ -326,6 +331,7 @@ static void *playlist_worker(void *arg)
g_plloading = 0; g_plloading = 0;
g_plgen++; g_plgen++;
pthread_mutex_unlock(&g_plmu); pthread_mutex_unlock(&g_plmu);
if (gate_rc != NCM_ERR_STALE && gate_rc != NCM_OK) appstate_session_gate(gate_rc);
return NULL; return NULL;
} }
+1
View File
@@ -129,6 +129,7 @@ static void sr_draw_empty(void *ctx)
if (sr_view() != SR_EMPTY) return; /* 历史/结果态有行,空态不画 */ if (sr_view() != SR_EMPTY) return; /* 历史/结果态有行,空态不画 */
if (g_loading) { ncm_draw_empty(r_list, "搜索中", "正在搜索..."); return; } if (g_loading) { ncm_draw_empty(r_list, "搜索中", "正在搜索..."); return; }
if (g_st.search_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录"); if (g_st.search_err == NCM_ERR_AUTH) ncm_draw_empty(r_list, "登录已过期", "请重新登录");
else if (appstate_is_offline() && g_st.search_err) ncm_draw_empty(r_list, "离线模式", "搜索需要网络 · 可播放已缓存");
else if (g_st.search_err) ncm_draw_empty(r_list, "搜索失败", "点「搜索」重试"); else if (g_st.search_err) ncm_draw_empty(r_list, "搜索失败", "点「搜索」重试");
else if (!g_st.search_query[0]) ncm_draw_empty(r_list, "输入关键词", "输入后点「搜索」"); else if (!g_st.search_query[0]) ncm_draw_empty(r_list, "输入关键词", "输入后点「搜索」");
else if (!g_searched) ncm_draw_empty(r_list, "输入后点「搜索」", "或点历史记录"); else if (!g_searched) ncm_draw_empty(r_list, "输入后点「搜索」", "或点历史记录");
+64 -4
View File
@@ -15,6 +15,12 @@
* chunked 完整(0 分块末端) → BS_DONE → NCM_OK * chunked 完整(0 分块末端) → BS_DONE → NCM_OK
* chunked 中途关闭 → 截断 → 非 NCM_OK(NCM_ERR_IO) * 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 下): * 编译(在 ncm_app 下):
* gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/mbedtls/include \ * gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Icore -Ivendor/mbedtls/include \
* -c bench/http_truncate_sim.c -o /tmp/t.o * -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_n;
static int g_script_i; static int g_script_i;
static int g_close_rc; /* 脚本耗尽后的 ssl_read 返回值 */ 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) { 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; 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) { mbedtls_ssl_recv_timeout_t *f_recv_timeout) {
(void)ssl; (void)p_bio; (void)f_send; (void)f_recv; (void)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_free(mbedtls_ssl_context *ssl) { (void)ssl; }
void mbedtls_ssl_config_free(mbedtls_ssl_config *conf) { (void)conf; } void mbedtls_ssl_config_free(mbedtls_ssl_config *conf) { (void)conf; }
void mbedtls_x509_crt_init(mbedtls_x509_crt *crt) { (void)crt; } void mbedtls_x509_crt_init(mbedtls_x509_crt *crt) { (void)crt; }
int mbedtls_x509_crt_parse_file(mbedtls_x509_crt *crt, const char *path) { 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; } 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 */ /* 运行一个用例:装填脚本 → 发起 GET 下载 → 记录 rc / body_len / content_length */
typedef struct { int rc; size_t body_len; size_t content_length; } case_result_t; 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; ncm_http_req_t req;
memset(&req, 0, sizeof req); memset(&req, 0, sizeof req);
req.url = "https://ex.test/song.mp3"; req.url = "https://ex.test/song.mp3";
req.method = "GET"; req.method = "GET";
req.verify_ca = 0; /* 跳过 CA 校验,聚焦 body 终态判定 */ req.verify_ca = verify_ca; /* 0=跳过 CA 校验,聚焦 body 终态判定 */
req.timeout_ms = 3000; req.timeout_ms = 3000;
sink_cb_t sink; memset(&sink, 0, sizeof sink); 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; 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 int g_fail = 0;
static void check(const char *name, int ok, const char *detail) { static void check(const char *name, int ok, const char *detail) {
if (!ok) g_fail++; if (!ok) g_fail++;
@@ -286,6 +299,53 @@ int main(void) {
(r.rc != NCM_OK) && (r.rc == NCM_ERR_IO), dbg); (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);
}
/* ---- 场景 13(M9):chunked 分块尺寸畸形("ZZ" 非十六进制)→ 数据错误 NCM_ERR_JSON,
* 不得在 http_ret_norm 里被改判成 NCM_ERR_NET(否则误报离线)。 ---- */
{
static const char chunk[] =
"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n"
"ZZ\r\ndata"; /* 分块尺寸行非法 */
static const tr_chunk_t script[] = { { (const unsigned char *)chunk, sizeof chunk - 1 } };
case_result_t r = run_case(script, 1, 0);
snprintf(dbg, sizeof dbg, "rc=%d", r.rc);
check("chunked_bad_size -> NCM_ERR_JSON (数据错误,不改判 NET)",
r.rc == NCM_ERR_JSON, dbg);
}
printf("\n== HTTP TRUNCATE SIM: %s ==\n", g_fail ? "FAIL" : "ALL PASS"); printf("\n== HTTP TRUNCATE SIM: %s ==\n", g_fail ? "FAIL" : "ALL PASS");
return g_fail ? 1 : 0; return g_fail ? 1 : 0;
} }
+2 -1
View File
@@ -17,7 +17,8 @@
* -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ps_app.o * -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 \ * $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 * -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 退出码) * 运行:./pagination_sim (0 网络;PASS/FAIL 退出码)
*/ */
#include "../app/ncm_app_internal.h" #include "../app/ncm_app_internal.h"
+11 -1
View File
@@ -21,7 +21,8 @@
* -c bench/playlist_race_sim.c -o /tmp/pr.o * -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 \ * 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 * -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 退出码) * 运行:./playlist_race_sim (0 网络;PASS/FAIL 退出码)
*/ */
#include "../app/ncm_app_internal.h" #include "../app/ncm_app_internal.h"
@@ -136,6 +137,15 @@ int ncm_api_user_playlist(const char *device_id, const char *cookie, const char
return NCM_OK; return NCM_OK;
} }
/* M9 出参变体(appstate_load_playlists_ex 引用;本 sim 恒「走网络」) */
int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
int *out_from_cache)
{
if (out_from_cache) *out_from_cache = 0;
return ncm_api_user_playlist(device_id, cookie, uid, limit, offset, out, max, out_n);
}
static int g_fail = 0; static int g_fail = 0;
#define CHECK(cond, fmt, ...) do { \ #define CHECK(cond, fmt, ...) do { \
if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \ if (cond) printf(" PASS " fmt "\n", ##__VA_ARGS__); \
+6
View File
@@ -69,6 +69,12 @@ int ncm_ctx_clear_cookie(ncm_ctx_t *ctx) { (void)ctx; return NCM_OK; }
int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid, int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n) int limit, int offset, ncm_playlist_t *out, int max, int *out_n)
{ (void)device_id;(void)cookie;(void)uid;(void)limit;(void)offset;(void)out;(void)max;(void)out_n; return NCM_ERR_NET; } { (void)device_id;(void)cookie;(void)uid;(void)limit;(void)offset;(void)out;(void)max;(void)out_n; return NCM_ERR_NET; }
/* M9 出参变体(appstate_load_playlists_ex 引用;本 sim 不到达) */
int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
int *out_from_cache)
{ if (out_from_cache) *out_from_cache = 0;
return ncm_api_user_playlist(device_id, cookie, uid, limit, offset, out, max, out_n); }
/* ---- 网络端点 stub(离线,合成数据;计调用次数 + 可注入单批失败) ---- */ /* ---- 网络端点 stub(离线,合成数据;计调用次数 + 可注入单批失败) ---- */
#define SIM_TOTAL 400 /* 4 页:100 × 4(> NCM_SONG_WINDOW 256,回滚必滑窗) */ #define SIM_TOTAL 400 /* 4 页:100 × 4(> NCM_SONG_WINDOW 256,回滚必滑窗) */
+2 -1
View File
@@ -10,7 +10,8 @@
* -Icore -c bench/search_sim.c -o /tmp/ss.o * -Icore -c bench/search_sim.c -o /tmp/ss.o
* $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \ * $CC_HOST -O2 -ffunction-sections -DJSMN_HEADER -I. -I../sdk -I../meow_launcherd \
* -Icore -c app/logic/appstate.c -o /tmp/ss_app.o * -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 退出码) * 运行:./search_sim (0 网络;PASS/FAIL 退出码)
*/ */
#include "../app/ncm_app_internal.h" #include "../app/ncm_app_internal.h"
+471
View File
@@ -0,0 +1,471 @@
/* 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)。
* D2. `ncm_api_user_playlist_ex` 的 from_cache 出参:miss→0(真网络 +1 次)、命中→1(0 请求)、
* 失败→0;兼容入口不崩;out_n 必填契约(NULL→NCM_ERR_ARG)(离线判定不得用全局计数,
* 评审 P2-2/P2-1)。
* 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <sys/stat.h>
#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, "<html>401</html>", 0, NCM_ERR_AUTH, "HTTP 401 → AUTH(退登录)" },
{ 403, "{}", 0, NCM_ERR_AUTH, "HTTP 403 → AUTH" },
{ 500, "<html>oops</html>", 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, "<html>not json</html>", 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);
}
}
/* ============================================================
* D2. user_playlist_ex:本次结果是否来自接口缓存(评审 P2-2)
* ============================================================ */
static void test_api_from_cache(void)
{
printf("== D2. ncm_api_user_playlist_ex 缓存命中出参(离线判定用)==\n");
ncm_apicache_init(NULL);
ncm_apicache_clear();
const char *body = "{\"code\":200,\"playlist\":[{\"id\":1,\"name\":\"a\",\"trackCount\":1}]}";
ncm_playlist_t pls[4];
int n = 0, fc = -1, rc;
stub(200, body, NULL);
int calls0 = g_http_calls;
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n, &fc);
CHECK(rc == NCM_OK && fc == 0 && g_http_calls == calls0 + 1 && n == 1,
"首次(缓存 miss):rc=0 n=%d from_cache=%d,真网络 +1 次(calls %d→%d)",
n, fc, calls0, g_http_calls);
fc = -1;
calls0 = g_http_calls;
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n, &fc);
CHECK(rc == NCM_OK && fc == 1 && g_http_calls == calls0 && n == 1,
"二次同参(缓存命中):rc=0 from_cache=%d,未发网络请求(calls=%d)",
fc, g_http_calls);
/* 失败不得把 from_cache 留成 1(App 会据此判离线,方向必须正确) */
ncm_apicache_clear();
stub_err(NCM_ERR_NET);
fc = -1;
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n, &fc);
CHECK(rc == NCM_ERR_NET && fc == 0, "网络失败:rc=%d from_cache=%d(期望 -4/0)", rc, fc);
/* 兼容入口仍可用(out_from_cache=NULL 不崩) */
ncm_apicache_clear();
stub(200, body, NULL);
rc = ncm_api_user_playlist("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, &n);
CHECK(rc == NCM_OK && n == 1, "兼容入口 ncm_api_user_playlist 正常(rc=%d n=%d)", rc, n);
/* 契约:out_n 必填(与原接口一致);out_from_cache 可 NULL(评审 P2-1) */
rc = ncm_api_user_playlist_ex("dev", "MUSIC_U=x", "1", 30, 0, pls, 4, NULL, &fc);
CHECK(rc == NCM_ERR_ARG, "out_n=NULL → NCM_ERR_ARG(契约与原接口一致)rc=%d", rc);
ncm_apicache_clear();
}
/* ============================================================
* 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, "<html>bad gateway</html>", 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, "<html>bad gateway</html>", 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_api_from_cache();
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;
}
+2 -1
View File
@@ -20,7 +20,8 @@
* -Icore -I../vendor/jsmn -c app/logic/appstate.c -o /tmp/ws_app.o * -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 \ * $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 * -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 退出码) * 运行:./window_sim (0 网络;PASS/FAIL 退出码)
*/ */
#include "../app/ncm_app_internal.h" #include "../app/ncm_app_internal.h"
+1 -1
View File
@@ -7,7 +7,7 @@ INC_CORE="-Icore"
INC_VENDOR="-I$VENDOR/mbedtls/include -I$VENDOR/jsmn -I$VENDOR/qrcodegen/c" INC_VENDOR="-I$VENDOR/mbedtls/include -I$VENDOR/jsmn -I$VENDOR/qrcodegen/c"
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \ core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c" core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c core/ncm_session.c"
VENDOR_C="vendor/qrcodegen/c/qrcodegen.c" # qrcodegen 单独编译 VENDOR_C="vendor/qrcodegen/c/qrcodegen.c" # qrcodegen 单独编译
JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU JSMN_IMPL="core/jsmn_impl.c" # jsmn 唯一实现 TU
+1 -1
View File
@@ -16,7 +16,7 @@ STRICT="-Wall -Wextra -Os -DJSMN_HEADER"
CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \ CORE="core/ncm_common.c core/ncm_json.c core/ncm_store.c core/ncm_http.c \
core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \ core/ncm_crypto.c core/ncm_req.c core/ncm_login.c core/ncm_api.c \
core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c" core/ncm_cache.c core/ncm_ctx.c core/ncm_apicache.c core/ncm_tag.c core/ncm_session.c"
APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \ APP="app/app.c app/ui/login.c app/ui/home.c app/ui/playlist.c app/ui/player.c \
app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \ app/ui/settings.c app/ui/search.c app/ui/minibar.c app/ui/queue.c app/ui/cache.c \
app/ui/ncm_colors.c app/ui/chrome.c \ app/ui/ncm_colors.c app/ui/chrome.c \
+18 -4
View File
@@ -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 \ 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 -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 \ 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 run_sim window_sim
echo "==> build plpage_sim(歌单页懒加载分页 + 加载后列表刷新;真实 playlist.c 全链路 + 宿主 SDK,0 网络。" 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 done
gcc -O2 -Wl,--gc-sections -Wl,--wrap=meow_ui_paint_full -o plpage_sim \ 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 \ /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 out/host/sdk_*.o -lz -lpthread -lm
run_sim plpage_sim 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 \ 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 -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 \ 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 run_sim search_sim
echo "==> build cache_evict_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)。 # 故不链 ncm_http.o)+ jsmn + 宿主 mbedtls;核心对象从 out/core-host/*.o 取(含 ncm_apicache.o)。
gcc -O2 -o apicache_sim /tmp/ac.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 \ $(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 \ out/core-host/jsmn_impl.o \
-Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm
run_sim apicache_sim 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 \ 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 \ $(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/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 \ out/core-host/jsmn_impl.o \
-Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm -Lout/mbedtls/host -lmbedtls -lmbedx509 -lmbedcrypto -lpthread -lm
run_sim cookie_refresh_sim 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 真实网络)" echo "==> build account_cookie_sim (§NEW 临期续期决策;真实 account.c + 外围桩,0 真实网络)"
gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \ gcc -O2 -Wall -Wextra -DJSMN_HEADER -I. -Iapp -Icore -I../sdk -I../meow_launcherd \
-Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \ -Ivendor/jsmn -Ivendor/qrcodegen/c -Ivendor/mbedtls/include \
+1
View File
@@ -7,6 +7,7 @@
#define NCM_H #define NCM_H
#include "ncm_common.h" #include "ncm_common.h"
#include "ncm_session.h"
#include "ncm_crypto.h" #include "ncm_crypto.h"
#include "ncm_http.h" #include "ncm_http.h"
#include "ncm_json.h" #include "ncm_json.h"
+41 -10
View File
@@ -5,6 +5,7 @@
#include "ncm_http.h" #include "ncm_http.h"
#include "ncm_json.h" #include "ncm_json.h"
#include "ncm_req.h" #include "ncm_req.h"
#include "ncm_session.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -101,13 +102,24 @@ static int api_parse_body(api_json_t *c) {
long code; long code;
if (ncm_json_path_int(&c->j, "code", &code) == NCM_OK && code != 200) { if (ncm_json_path_int(&c->j, "code", &code) == NCM_OK && code != 200) {
int ec = NCM_ERR_HTTP; int ec = NCM_ERR_HTTP;
if (code == 301 || code == -460 || code == 2000 || code == 201) ec = NCM_ERR_AUTH; if (code == 301 || code == 302 || code == 2000 || code == 201) ec = NCM_ERR_AUTH;
else if (code == -460) ec = NCM_ERR_SVC; /* 风控/请求被拒:可重试,不是登录失效(M9) */
api_free(c); api_free(c);
return ec; return ec;
} }
return NCM_OK; return NCM_OK;
} }
/* HTTP 状态门(M9):非 2xx 先按状态定性,不再一律丢给 JSON 解析。
* 401/403 → NCM_ERR_AUTH(回扫码)
* 408/425/429/5xx → NCM_ERR_SVC(暂不可用:保留登录态、进离线/重试)
* 其它 3xx/4xx → NCM_ERR_HTTP(请求/服务异常,不登出)
* status<100 = 响应头未解析(旧桩/异常):返回 NCM_OK,沿用 body 判定。 */
static int api_status_gate(const ncm_http_resp_t *r) {
if (r->status < 100) return NCM_OK;
return ncm_http_status_err(r->status);
}
static int api_post(const char *device_id, const char *cookie, const char *path, static int api_post(const char *device_id, const char *cookie, const char *path,
const char *json, int kind, size_t resp_cap, api_json_t *c) { const char *json, int kind, size_t resp_cap, api_json_t *c) {
char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX]; char body[NCM_REQ_BODY_MAX], hdrs[NCM_REQ_HDRS_MAX], url[NCM_REQ_URL_MAX];
@@ -124,6 +136,8 @@ static int api_post(const char *device_id, const char *cookie, const char *path,
if (!c->rbuf) return NCM_ERR_OOM; if (!c->rbuf) return NCM_ERR_OOM;
rc = ncm_http_request(&req, c->rbuf, resp_cap, &c->resp); rc = ncm_http_request(&req, c->rbuf, resp_cap, &c->resp);
if (rc != NCM_OK) { free(c->rbuf); c->rbuf = NULL; return rc; } if (rc != NCM_OK) { free(c->rbuf); c->rbuf = NULL; return rc; }
rc = api_status_gate(&c->resp);
if (rc != NCM_OK) { free(c->rbuf); c->rbuf = NULL; return rc; }
return api_parse_body(c); return api_parse_body(c);
} }
@@ -160,9 +174,14 @@ static void api_cache_key(const char *path, const char *json, char *out, size_t
} }
/* 带缓存的 POST:先查内存/盘缓存(TTL 内命中则直接解析回填);miss/过期/手动刷新 → 真网络, /* 带缓存的 POST:先查内存/盘缓存(TTL 内命中则直接解析回填);miss/过期/手动刷新 → 真网络,
* 成功且非手动刷新 → 写缓存。 */ * 成功且非手动刷新 → 写缓存。
* from_cache(可 NULL):1=本次结果来自接口缓存(**没有**真正走网络),0=走了网络/失败。
* 离线判定用它——缓存命中返回 NCM_OK 不代表网络已恢复(M9 评审 P2-2:不能用全局计数
* 前后差值判定,其它 worker 的命中会污染,且计数自增存在跨线程竞争)。 */
static int api_post_cached(const char *device_id, const char *cookie, const char *path, static int api_post_cached(const char *device_id, const char *cookie, const char *path,
const char *json, int kind, size_t resp_cap, api_json_t *c) { const char *json, int kind, size_t resp_cap, api_json_t *c,
int *from_cache) {
if (from_cache) *from_cache = 0;
int refresh = api_refresh_match(path); int refresh = api_refresh_match(path);
char key[NCM_APICACHE_KEY_MAX]; char key[NCM_APICACHE_KEY_MAX];
api_cache_key(path, json, key, sizeof key); api_cache_key(path, json, key, sizeof key);
@@ -172,6 +191,7 @@ static int api_post_cached(const char *device_id, const char *cookie, const char
if (buf) { if (buf) {
size_t len = 0; size_t len = 0;
if (ncm_apicache_get(key, buf, resp_cap, &len) == NCM_OK) { if (ncm_apicache_get(key, buf, resp_cap, &len) == NCM_OK) {
if (from_cache) *from_cache = 1;
c->rbuf = buf; c->rbuf = buf;
c->resp.body = buf; /* 缓存的 body 从缓冲头开始 */ c->resp.body = buf; /* 缓存的 body 从缓冲头开始 */
c->resp.body_len = len; c->resp.body_len = len;
@@ -286,10 +306,13 @@ static int parse_songs_array(const ncm_json_t *j, int arr, ncm_song_t *out, int
return NCM_OK; return NCM_OK;
} }
int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid, int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n) { int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
int *out_from_cache) {
/* 与原接口契约一致:out_n 必填(成功路径无条件回填);out_from_cache 可 NULL。 */
if (!out_n) return NCM_ERR_ARG; if (!out_n) return NCM_ERR_ARG;
*out_n = 0; *out_n = 0;
if (out_from_cache) *out_from_cache = 0;
if (!uid) return NCM_ERR_ARG; if (!uid) return NCM_ERR_ARG;
char json[256]; char json[256];
snprintf(json, sizeof json, "{\"uid\":\"%s\",\"limit\":%d,\"offset\":%d,\"includeVideo\":true}", snprintf(json, sizeof json, "{\"uid\":\"%s\",\"limit\":%d,\"offset\":%d,\"includeVideo\":true}",
@@ -300,7 +323,7 @@ int ncm_api_user_playlist(const char *device_id, const char *cookie, const char
* (日志 home: playlists rc=-11)。该响应不含 tracks(每首 tracks:null),1MB 足够, * (日志 home: playlists rc=-11)。该响应不含 tracks(每首 tracks:null),1MB 足够,
* 用 NCM_RESP_MAX(与 vip/info 同口径的堆瞬态,解析后即释,非 bss)。 */ * 用 NCM_RESP_MAX(与 vip/info 同口径的堆瞬态,解析后即释,非 bss)。 */
int rc = api_post_cached(device_id, cookie, "/api/user/playlist", json, NCM_CRYPT_WEAPI, int rc = api_post_cached(device_id, cookie, "/api/user/playlist", json, NCM_CRYPT_WEAPI,
NCM_RESP_MAX, &c); NCM_RESP_MAX, &c, out_from_cache);
if (rc != NCM_OK) return rc; if (rc != NCM_OK) return rc;
int arr = ncm_json_path(&c.j, "playlist"); int arr = ncm_json_path(&c.j, "playlist");
@@ -323,6 +346,12 @@ int ncm_api_user_playlist(const char *device_id, const char *cookie, const char
return NCM_OK; return NCM_OK;
} }
/* 兼容入口(不关心是否缓存命中):等价 ncm_api_user_playlist_ex(..., NULL)。 */
int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n) {
return ncm_api_user_playlist_ex(device_id, cookie, uid, limit, offset, out, max, out_n, NULL);
}
int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id, int ncm_api_playlist_detail(const char *device_id, const char *cookie, const char *playlist_id,
char *name, size_t name_sz, char *cover, size_t cover_sz, char *name, size_t name_sz, char *cover, size_t cover_sz,
int64_t *ids, int id_cap, int *out_id_n) { int64_t *ids, int id_cap, int *out_id_n) {
@@ -336,7 +365,7 @@ int ncm_api_playlist_detail(const char *device_id, const char *cookie, const cha
api_json_t c; memset(&c, 0, sizeof c); api_json_t c; memset(&c, 0, sizeof c);
int rc = api_post_cached(device_id, cookie, "/api/v6/playlist/detail", json, NCM_CRYPT_EAPI, int rc = api_post_cached(device_id, cookie, "/api/v6/playlist/detail", json, NCM_CRYPT_EAPI,
2 * 1024 * 1024, &c); 2 * 1024 * 1024, &c, NULL);
if (rc != NCM_OK) return rc; if (rc != NCM_OK) return rc;
int pl = ncm_json_path(&c.j, "playlist"); int pl = ncm_json_path(&c.j, "playlist");
@@ -380,7 +409,7 @@ int ncm_api_song_detail(const char *device_id, const char *cookie, const int64_t
api_json_t c; memset(&c, 0, sizeof c); api_json_t c; memset(&c, 0, sizeof c);
int rc = api_post_cached(device_id, cookie, "/api/v3/song/detail", json, NCM_CRYPT_EAPI, int rc = api_post_cached(device_id, cookie, "/api/v3/song/detail", json, NCM_CRYPT_EAPI,
RESP_MED, &c); RESP_MED, &c, NULL);
if (rc != NCM_OK) return rc; if (rc != NCM_OK) return rc;
int arr = ncm_json_path(&c.j, "songs"); int arr = ncm_json_path(&c.j, "songs");
@@ -570,7 +599,9 @@ int ncm_api_parse_vip_info(const char *json, size_t len, int64_t now_ms, ncm_vip
long code = 200; long code = 200;
if (ncm_json_path_int(&j, "code", &code) == NCM_OK && code != 200) { if (ncm_json_path_int(&j, "code", &code) == NCM_OK && code != 200) {
free(toks); free(parent); free(toks); free(parent);
return (code == 301 || code == -460) ? NCM_ERR_AUTH : NCM_ERR_HTTP; if (code == 301 || code == 302) return NCM_ERR_AUTH;
if (code == -460) return NCM_ERR_SVC; /* 风控:暂不可用,不是登录失效 */
return NCM_ERR_HTTP;
} }
/* root = data 对象优先,否则 body 本身(v2 挂 data,v1 字段直接在根部) */ /* root = data 对象优先,否则 body 本身(v2 挂 data,v1 字段直接在根部) */
@@ -661,7 +692,7 @@ static int cloudsearch_post(const char *device_id, const char *cookie, const cha
snprintf(json, sizeof json, "{\"s\":\"%s\",\"type\":%d,\"offset\":%d,\"limit\":%d,\"total\":true}", snprintf(json, sizeof json, "{\"s\":\"%s\",\"type\":%d,\"offset\":%d,\"limit\":%d,\"total\":true}",
esc, type, offset > 0 ? offset : 0, limit > 0 ? limit : 30); esc, type, offset > 0 ? offset : 0, limit > 0 ? limit : 30);
return api_post_cached(device_id, cookie, "/api/cloudsearch/pc", json, NCM_CRYPT_WEAPI, return api_post_cached(device_id, cookie, "/api/cloudsearch/pc", json, NCM_CRYPT_WEAPI,
RESP_MED, c); RESP_MED, c, NULL);
} }
/* 解析一处「歌单对象数组」(cloudsearch type=1000)。id 可为数字/字符串(均文本拷贝)。 */ /* 解析一处「歌单对象数组」(cloudsearch type=1000)。id 可为数字/字符串(均文本拷贝)。 */
+7 -1
View File
@@ -89,7 +89,13 @@ typedef struct ncm_play_url {
} ncm_play_url_t; } ncm_play_url_t;
/* 用户歌单列表(weapi /user/playlist)。uid 必填;limit/offset 翻页。 /* 用户歌单列表(weapi /user/playlist)。uid 必填;limit/offset 翻页。
* 返回 NCM_OK;songs/out_n 为列表。 */ * 返回 NCM_OK;songs/out_n 为列表。
* _ex 变体额外回填 *out_from_cache(可 NULL):1=本次结果来自接口缓存(未走网络),
* 0=走了网络或失败。离线判定用——缓存命中返回 NCM_OK 不代表网络已恢复(M9)。
* 契约与原接口一致:out_n 必填(NULL → NCM_ERR_ARG)。 */
int ncm_api_user_playlist_ex(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n,
int *out_from_cache);
int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid, int ncm_api_user_playlist(const char *device_id, const char *cookie, const char *uid,
int limit, int offset, ncm_playlist_t *out, int max, int *out_n); int limit, int offset, ncm_playlist_t *out, int max, int *out_n);
+2 -1
View File
@@ -222,7 +222,8 @@ int ncm_apicache_get(const char *key, char *buf, size_t buf_sz, size_t *len)
/* 内存 miss → 盘缓存兜底(命中回填内存)。 */ /* 内存 miss → 盘缓存兜底(命中回填内存)。 */
if (g_disk_on) { if (g_disk_on) {
int rc = disk_get(key, buf, buf_sz, len); int rc = disk_get(key, buf, buf_sz, len);
if (rc == NCM_OK) ncm_apicache_put(key, buf, *len); /* 合并为一行,行为不变(重构):len 为空时不回填内存。 */
if (rc == NCM_OK && len) ncm_apicache_put(key, buf, *len);
return rc; return rc;
} }
return NCM_ERR_NOENT; return NCM_ERR_NOENT;
+2
View File
@@ -32,6 +32,8 @@ enum {
NCM_ERR_LIMIT = -14, /* 超出上限级 */ NCM_ERR_LIMIT = -14, /* 超出上限级 */
NCM_ERR_VIP = -15, /* VIP 专享(fee 门槛命中,未发 song_url 请求,§20) */ NCM_ERR_VIP = -15, /* VIP 专享(fee 门槛命中,未发 song_url 请求,§20) */
NCM_ERR_STALE = -16, /* 异步结果过期(打开歌单 A 未完→打开 B,A 的迟到结果被丢弃,§26) */ NCM_ERR_STALE = -16, /* 异步结果过期(打开歌单 A 未完→打开 B,A 的迟到结果被丢弃,§26) */
NCM_ERR_SVC = -17, /* 服务端暂不可用(HTTP 5xx/429、业务码 -460 风控):可重试,
* **不是**登录失效(M9:不得据此清登录态/退扫码) */
}; };
/* ---------- 常用尺寸 ---------- */ /* ---------- 常用尺寸 ---------- */
+28 -1
View File
@@ -322,6 +322,33 @@ static int body_feed(body_st *b, const unsigned char *data, size_t len, sink_t *
return (int)i; return (int)i;
} }
/* 传输层返回值归一(M9):mbedtls 的裸负码(如握手失败 -0x7280)此前会原样穿透给上层,
* 上层按 NCM_ERR_* 分类时落到「未知错误」,既判不了离线也判不了鉴权——断网时的失败因此
* 不可信。此处统一收敛:超时 → NCM_ERR_TIMEOUT;证书校验失败 → NCM_ERR_TLS;其它传输失败
* (DNS/连接/握手/读写/对端 reset)→ NCM_ERR_NET。
* 已经是 NCM 错误码的一律原样返回(含 NCM_ERR_JSON:body 分块/帧体畸形是**数据**错误,
* 不能改判成网络不可达,否则会误报离线)。 */
static int http_ret_norm(int ret)
{
switch (ret) {
case NCM_OK:
case NCM_ERR_ARG:
case NCM_ERR_OOM:
case NCM_ERR_IO:
case NCM_ERR_NET:
case NCM_ERR_TLS:
case NCM_ERR_TIMEOUT:
case NCM_ERR_JSON:
case NCM_ERR_FULL:
return ret;
default:
break;
}
if (ret == MBEDTLS_ERR_SSL_TIMEOUT) return NCM_ERR_TIMEOUT;
if (ret == MBEDTLS_ERR_X509_CERT_VERIFY_FAILED) return NCM_ERR_TLS;
return NCM_ERR_NET;
}
/* ---------- 核心请求 ---------- */ /* ---------- 核心请求 ---------- */
static int http_run(const ncm_http_req_t *req, sink_t *sink, ncm_http_resp_t *resp) { static int http_run(const ncm_http_req_t *req, sink_t *sink, ncm_http_resp_t *resp) {
int ret; int ret;
@@ -505,7 +532,7 @@ done:
mbedtls_ssl_config_free(&conf); mbedtls_ssl_config_free(&conf);
mbedtls_x509_crt_free(&cacert); mbedtls_x509_crt_free(&cacert);
if (s_rng_fp) { fclose(s_rng_fp); s_rng_fp = NULL; } if (s_rng_fp) { fclose(s_rng_fp); s_rng_fp = NULL; }
return ret; return (ret == NCM_OK) ? NCM_OK : http_ret_norm(ret);
} }
int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp) { int ncm_http_request(const ncm_http_req_t *req, char *buf, size_t buf_sz, ncm_http_resp_t *resp) {
+25 -3
View File
@@ -4,6 +4,7 @@
#include "ncm_http.h" #include "ncm_http.h"
#include "ncm_json.h" #include "ncm_json.h"
#include "ncm_req.h" #include "ncm_req.h"
#include "ncm_session.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -161,6 +162,12 @@ int ncm_login_status_check(const char *device_id, const char *cookie,
ncm_http_resp_t resp; ncm_http_resp_t resp;
rc = ncm_http_request(&req, respbuf, sizeof respbuf, &resp); rc = ncm_http_request(&req, respbuf, sizeof respbuf, &resp);
if (rc != NCM_OK) return rc; if (rc != NCM_OK) return rc;
/* M9:HTTP 状态先定性——401/403 才是鉴权失败;5xx/429 是暂不可用(不得清登录态);
* 其它非 2xx 是请求/服务异常。status<100(响应头未解析)沿用 body 判定。 */
if (resp.status >= 100) {
int se = ncm_http_status_err(resp.status);
if (se != NCM_OK) return se;
}
ncm_json_t j; ncm_json_t j;
jsmntok_t toks[512]; jsmntok_t toks[512];
@@ -170,7 +177,12 @@ int ncm_login_status_check(const char *device_id, const char *cookie,
long code; long code;
if (ncm_json_path_int(&j, "code", &code) != NCM_OK) return NCM_ERR_JSON; if (ncm_json_path_int(&j, "code", &code) != NCM_OK) return NCM_ERR_JSON;
if (code != 200) return NCM_ERR_AUTH; if (code != 200) {
/* 服务端明确否定会话(301/302)→ AUTH;-460 风控 → SVC(可重试,不是过期)。 */
if (code == 301 || code == 302) return NCM_ERR_AUTH;
if (code == -460) return NCM_ERR_SVC;
return NCM_ERR_HTTP;
}
/* 未登录(guest)时 code 可能仍为 200 但 profile 为 null —— 必须 profile 是对象才算已登录 */ /* 未登录(guest)时 code 可能仍为 200 但 profile 为 null —— 必须 profile 是对象才算已登录 */
int ptok = ncm_json_path(&j, "profile"); int ptok = ncm_json_path(&j, "profile");
if (ptok < 0 || ncm_json_type(&j, ptok) != JSMN_OBJECT) return NCM_ERR_AUTH; if (ptok < 0 || ncm_json_type(&j, ptok) != JSMN_OBJECT) return NCM_ERR_AUTH;
@@ -210,7 +222,11 @@ int ncm_login_refresh(const char *device_id, const char *cookie,
ncm_http_resp_t resp; ncm_http_resp_t resp;
rc = ncm_http_request(&req, respbuf, sizeof respbuf, &resp); rc = ncm_http_request(&req, respbuf, sizeof respbuf, &resp);
if (rc != NCM_OK) return rc; /* 网络/TLS/超时 → 调用方退避 */ if (rc != NCM_OK) return rc; /* 网络/TLS/超时 → 调用方退避 */
if (resp.status != 200) return NCM_ERR_HTTP; /* 异常 HTTP → 调用方退避 */ /* M9:401/403 → 会话确失效(AUTH);5xx/429 → SVC 暂不可用(退避,绝不清登录态)。 */
if (resp.status >= 100) {
int se = ncm_http_status_err(resp.status);
if (se != NCM_OK) return se;
}
/* 提取响应 set-cookie(会话轮换:MUSIC_U/__csrf),patch 进现存 cookie */ /* 提取响应 set-cookie(会话轮换:MUSIC_U/__csrf),patch 进现存 cookie */
char captured[4096] = ""; char captured[4096] = "";
@@ -224,7 +240,13 @@ int ncm_login_refresh(const char *device_id, const char *cookie,
long code = -1; long code = -1;
if (ncm_json_path_int(&j, "code", &code) != NCM_OK) return NCM_ERR_JSON; if (ncm_json_path_int(&j, "code", &code) != NCM_OK) return NCM_ERR_JSON;
if (code != 200) return NCM_ERR_AUTH; /* 301=未登录 / -460=风控 / 其它 → 会话失效,重登 */ if (code != 200) {
/* 301/302=未登录/会话失效 → AUTH(重登);-460=风控 → SVC(退避,保留旧 cookie);
* 其它业务码语义不明 → HTTP(退避,不因一次未知响应把用户踢回扫码)。 */
if (code == 301 || code == 302) return NCM_ERR_AUTH;
if (code == -460) return NCM_ERR_SVC;
return NCM_ERR_HTTP;
}
if (out_cookie) if (out_cookie)
ncm_req_cookie_merge(cookie, captured, out_cookie, out_cookie_sz); ncm_req_cookie_merge(cookie, captured, out_cookie, out_cookie_sz);
+8 -4
View File
@@ -43,18 +43,22 @@ typedef struct ncm_login_poll {
int ncm_login_poll(const char *device_id, const char *unikey, const char *cur_cookie, int ncm_login_poll(const char *device_id, const char *unikey, const char *cur_cookie,
ncm_login_poll_t *r, char *out_cookie, size_t out_cookie_sz); ncm_login_poll_t *r, char *out_cookie, size_t out_cookie_sz);
/* login_status(weapi):校验 cookie 是否仍有效。有效写 uid/nickname 并返回 NCM_OK;失效返回 NCM_ERR_AUTH。 */ /* login_status(weapi):校验 cookie 是否仍有效。有效写 uid/nickname 并返回 NCM_OK;
* 服务端明确否定(HTTP 401/403、业务码 301/302、profile 为空)返回 NCM_ERR_AUTH;
* 网络/超时/TLS → NCM_ERR_NET/TIMEOUT/TLS;5xx/429、业务码 -460 → NCM_ERR_SVC
* (调用方**不得**据此清登录态,见 core/ncm_session.h 的登录门语义)。 */
int ncm_login_status_check(const char *device_id, const char *cookie, int ncm_login_status_check(const char *device_id, const char *cookie,
char *uid, size_t usz, char *nickname, size_t nsz); char *uid, size_t usz, char *nickname, size_t nsz);
/* 登录续期(eapi /api/login/token/refresh,空体):带当前 cookie 换取新登录态。 /* 登录续期(eapi /api/login/token/refresh,空体):带当前 cookie 换取新登录态。
* 成功(code==200)把响应 set-cookie 的新 MUSIC_U/__csrf patch 进 cookie 写 out_cookie 并返 * 成功(code==200)把响应 set-cookie 的新 MUSIC_U/__csrf patch 进 cookie 写 out_cookie 并返
* NCM_OK;code!=200(301/-460/会话失效)返 NCM_ERR_AUTH(调用方应清登录态进扫码);网络/TLS/ * NCM_OK;服务端明确否定(301/302)返 NCM_ERR_AUTH(调用方清登录态进扫码);
* 超时/HTTP 异常返负错误码(调用方退避,保持旧 cookie)。 */ * -460(风控)/其它非 200 返 NCM_ERR_SVC/NCM_ERR_HTTP(退避,保持旧 cookie);
* 网络/TLS/超时/HTTP 5xx 返负错误码(调用方退避,保持旧 cookie)。 */
int ncm_login_refresh(const char *device_id, const char *cookie, int ncm_login_refresh(const char *device_id, const char *cookie,
char *out_cookie, size_t out_cookie_sz); char *out_cookie, size_t out_cookie_sz);
/* 会话 cookie 加密落盘/读回(机器绑定密钥,仅防裸读)。 */ /* 会话 cookie 加密落盘/读回(设备私有 .mkey 派生密钥,仅防裸读;旧 MAC 密文自动迁移)。 */
int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie); int ncm_login_save_cookie(const ncm_store_t *store, const char *cookie);
int ncm_login_load_cookie(const ncm_store_t *store, char *cookie, size_t sz); int ncm_login_load_cookie(const ncm_store_t *store, char *cookie, size_t sz);
int ncm_login_clear_cookie(const ncm_store_t *store); int ncm_login_clear_cookie(const ncm_store_t *store);
+87
View File
@@ -0,0 +1,87 @@
/* ncm_session.c — 会话保持 / 离线模式的错误分类与登录门决策(纯逻辑,见头文件语义) */
#include "ncm_session.h"
ncm_fail_class_t ncm_err_classify(int rc)
{
switch (rc) {
case NCM_OK: return NCM_FAIL_NONE;
case NCM_ERR_AUTH: return NCM_FAIL_AUTH;
case NCM_ERR_NET:
case NCM_ERR_TLS:
case NCM_ERR_TIMEOUT:
case NCM_ERR_SVC: return NCM_FAIL_NETWORK;
default: break;
}
/* 防御:传输层历史泄漏的 mbedtls 裸负码(远小于 NCM 错误码区间)仍按网络类处理,
* 避免「连不上」被上层当未知错误而走上退出登录/报错数据的分支。 */
if (rc < -100) return NCM_FAIL_NETWORK;
return NCM_FAIL_OTHER;
}
int ncm_err_is_network(int rc)
{
return ncm_err_classify(rc) == NCM_FAIL_NETWORK;
}
ncm_http_cls_t ncm_http_status_class(int status)
{
if (status >= 200 && status <= 299) return NCM_HTTP_CLS_OK;
if (status == 401 || status == 403) return NCM_HTTP_CLS_AUTH;
if (status == 408 || status == 425 || status == 429) return NCM_HTTP_CLS_TRANSIENT;
if (status >= 500 && status <= 599) return NCM_HTTP_CLS_TRANSIENT;
return NCM_HTTP_CLS_ERROR;
}
int ncm_http_status_err(int status)
{
switch (ncm_http_status_class(status)) {
case NCM_HTTP_CLS_OK: return NCM_OK;
case NCM_HTTP_CLS_AUTH: return NCM_ERR_AUTH;
case NCM_HTTP_CLS_TRANSIENT: return NCM_ERR_SVC;
default: return NCM_ERR_HTTP;
}
}
ncm_gate_t ncm_gate_decide(int logged_in, int rc)
{
if (!logged_in) return NCM_GATE_RELOGIN;
if (rc == NCM_OK) return NCM_GATE_ONLINE;
switch (ncm_err_classify(rc)) {
case NCM_FAIL_AUTH: return NCM_GATE_RELOGIN;
case NCM_FAIL_NETWORK: return NCM_GATE_OFFLINE;
default: return NCM_GATE_HOLD;
}
}
ncm_gate_t ncm_session_boot_decide(int has_cookie, int probe_rc)
{
if (!has_cookie) return NCM_GATE_RELOGIN;
if (probe_rc == NCM_OK) return NCM_GATE_ONLINE;
switch (ncm_err_classify(probe_rc)) {
case NCM_FAIL_AUTH: return NCM_GATE_RELOGIN;
case NCM_FAIL_NETWORK: return NCM_GATE_OFFLINE;
default: return NCM_GATE_ONLINE; /* 不确定就不赶人 */
}
}
const char *ncm_gate_name(ncm_gate_t g)
{
switch (g) {
case NCM_GATE_ONLINE: return "online";
case NCM_GATE_OFFLINE: return "offline";
case NCM_GATE_RELOGIN: return "relogin";
case NCM_GATE_HOLD: return "hold";
default: return "?";
}
}
const char *ncm_fail_class_name(ncm_fail_class_t c)
{
switch (c) {
case NCM_FAIL_NONE: return "none";
case NCM_FAIL_NETWORK: return "network";
case NCM_FAIL_AUTH: return "auth";
case NCM_FAIL_OTHER: return "other";
default: return "?";
}
}
+73
View File
@@ -0,0 +1,73 @@
/* ncm_session.h — 会话保持 / 离线模式:错误分类与登录门决策(纯逻辑,无 IO/无 UI)
*
* 用户定案(M9):网络不可达 / 超时 / DNS / 连接失败 / 服务端 5xx 等**传输层或服务端暂不可用**
* 一律保持账号态、进入离线模式(可看可播缓存列表,不弹扫码);只有服务端**明确**否定会话
* (HTTP 401/403、业务码 301/302 等)才沿用原退出登录逻辑回扫码。
*
* 分层:http 层把 mbedtls 裸负码归一为 NCM_ERR_*(ncm_http.c);api/login 层把 HTTP 状态与
* 业务码映射为 NCM_ERR_*(ncm_api.c / ncm_login.c);本模块只做「错误码 → 语义分类」与
* 「分类 → 登录门动作」的纯判定,供 app 层(g_st.offline / 回登录页)与宿主单测共用。
*/
#ifndef NCM_SESSION_H
#define NCM_SESSION_H
#include "ncm_common.h"
#ifdef __cplusplus
extern "C" {
#endif
/* 失败分类 */
typedef enum {
NCM_FAIL_NONE = 0, /* 成功 */
NCM_FAIL_NETWORK, /* 传输层/服务端暂不可用:可重试;保留账号态,进离线 */
NCM_FAIL_AUTH, /* 服务端明确未登录/鉴权失败:退出登录,回扫码 */
NCM_FAIL_OTHER, /* 参数/解析/数据/文件错误:既不算离线也不登出 */
} ncm_fail_class_t;
/* 登录门动作(一次网络动作的结果落到哪) */
typedef enum {
NCM_GATE_ONLINE = 0, /* 成功:在线(离线模式随之解除) */
NCM_GATE_OFFLINE, /* 网络类失败:保持登录态 + 离线模式 */
NCM_GATE_RELOGIN, /* 鉴权失败(或本地无会话):清登录态,回扫码 */
NCM_GATE_HOLD, /* 其它错误:保持现状(不改登录态/离线态) */
} ncm_gate_t;
/* HTTP 状态分类(api/login 层的状态门用) */
typedef enum {
NCM_HTTP_CLS_OK = 0, /* 2xx */
NCM_HTTP_CLS_AUTH, /* 401/403:明确鉴权失败 */
NCM_HTTP_CLS_TRANSIENT, /* 408/425/429/5xx:暂不可用,可重试 */
NCM_HTTP_CLS_ERROR, /* 其它非 2xx(含 3xx):请求/服务异常,非鉴权 */
} ncm_http_cls_t;
/* NCM_ERR_* → 失败分类。未知负码(含历史泄漏的 mbedtls 裸码)按 NETWORK 兜底:
* 宁可保留登录态/进离线,也不把「连不上」误判成「会话过期」。 */
ncm_fail_class_t ncm_err_classify(int rc);
/* 便捷判定:rc 是否属于网络/服务端暂不可用类。 */
int ncm_err_is_network(int rc);
/* HTTP 状态 → 分类;→ 对应 NCM_ERR_*(AUTH / SVC / HTTP / OK)。 */
ncm_http_cls_t ncm_http_status_class(int status);
int ncm_http_status_err(int status);
/* 登录门决策:logged_in=本地是否有会话(cookie 非空)。
* !logged_in → RELOGIN(无会话可用)
* rc==NCM_OK → ONLINE
* AUTH → RELOGIN
* NETWORK → OFFLINE(保持账号态)
* 其它 → HOLD(保持现状,UI 自行提示重试) */
ncm_gate_t ncm_gate_decide(int logged_in, int rc);
/* 启动决策:本地会话是唯一凭据——有会话就先信它进 App(无网也进 home/缓存),
* 只有服务端明确否定才回扫码。probe_rc 为启动探测/首个请求的返回码(无探测=NCM_OK)。 */
ncm_gate_t ncm_session_boot_decide(int has_cookie, int probe_rc);
/* 可读名(日志/单测断言输出)。 */
const char *ncm_gate_name(ncm_gate_t g);
const char *ncm_fail_class_name(ncm_fail_class_t c);
#ifdef __cplusplus
}
#endif
#endif /* NCM_SESSION_H */
+59 -9
View File
@@ -170,6 +170,28 @@ int ncm_store_machine_key(const char *salt, unsigned char key[16]) {
return 0; return 0;
} }
/* 设备私有密钥派生:sha256(.mkey_hex + 盐)。.mkey 是首次生成的 32 字节随机数,
* 与网络状态/网卡集合无关。 */
static int key_from_mkey(const char *salt, unsigned char key[16]) {
unsigned char mkey[32], h[32];
if (get_or_create_mkey(mkey) != 0) return -1;
char hex[65], buf[96];
ncm_hex_encode(mkey, 32, hex, 0);
snprintf(buf, sizeof buf, "%s%s", hex, salt ? salt : "");
if (ncm_sha256(buf, strlen(buf), h) != 0) return -1;
memcpy(key, h, 16);
return 0;
}
/* 会话密钥(M9 根因修复):优先设备私有 .mkey,只有它不可用(只读文件系统/无权限)才回退
* 旧版 MAC 派生。旧版密钥依赖「首选网卡 MAC」——设备断网时网卡可能从 /sys/class/net 消失,
* 或 readdir 顺序变化选到另一块网卡,派生结果随之改变 → session.enc 解不开 → App 以为
* 「未登录」直接进扫码页(用户报告的「一断网就退到扫码登录」正是该路径)。 */
static int session_key(const char *salt, unsigned char key[16]) {
if (key_from_mkey(salt, key) == 0) return 0;
return ncm_store_machine_key(salt, key);
}
static int pkcs7_pad_len(size_t n) { static int pkcs7_pad_len(size_t n) {
return (int)(16 - (n % 16)); /* 1..16 */ return (int)(16 - (n % 16)); /* 1..16 */
} }
@@ -178,7 +200,7 @@ int ncm_store_cookie_encrypt(const char *plain,
unsigned char *out, size_t *out_len, size_t cap) { unsigned char *out, size_t *out_len, size_t cap) {
if (!plain || !out || !out_len) return NCM_ERR_ARG; if (!plain || !out || !out_len) return NCM_ERR_ARG;
unsigned char key[16]; unsigned char key[16];
if (ncm_store_machine_key(SESSION_SALT, key) != 0) return NCM_ERR_CRYPTO; if (session_key(SESSION_SALT, key) != 0) return NCM_ERR_CRYPTO;
size_t n = strlen(plain); size_t n = strlen(plain);
int pad = pkcs7_pad_len(n); int pad = pkcs7_pad_len(n);
@@ -212,13 +234,9 @@ int ncm_store_cookie_encrypt(const char *plain,
return NCM_OK; return NCM_OK;
} }
int ncm_store_cookie_decrypt(const unsigned char *in, size_t in_len, /* 用指定密钥解密已解包(魔数/iv 已剥离)的密文;成功返回 NCM_OK。 */
char *out, size_t out_sz) { static int cookie_decrypt_key(const unsigned char *in, size_t in_len,
if (!in || !out || !out_sz) return NCM_ERR_ARG; const unsigned char key[16], char *out, size_t out_sz) {
if (in_len < SESSION_MAGIC_LEN + 16 || memcmp(in, SESSION_MAGIC, SESSION_MAGIC_LEN) != 0)
return NCM_ERR_CRYPTO;
unsigned char key[16];
if (ncm_store_machine_key(SESSION_SALT, key) != 0) return NCM_ERR_CRYPTO;
const unsigned char *iv = in + SESSION_MAGIC_LEN; const unsigned char *iv = in + SESSION_MAGIC_LEN;
size_t clen = in_len - SESSION_MAGIC_LEN - 16; size_t clen = in_len - SESSION_MAGIC_LEN - 16;
if (clen == 0 || clen % 16) return NCM_ERR_CRYPTO; if (clen == 0 || clen % 16) return NCM_ERR_CRYPTO;
@@ -246,6 +264,34 @@ int ncm_store_cookie_decrypt(const unsigned char *in, size_t in_len,
return NCM_OK; return NCM_OK;
} }
/* 解密(*legacy 可选:回填 1=命中的是旧版 MAC 派生密钥,调用方据此迁移重加密):
* ① 新密钥(.mkey 派生)——当前写入格式;
* ② 旧密钥(MAC 派生)——兼容升级前落盘的 session.enc,解出即迁移。 */
static int cookie_decrypt_sel(const unsigned char *in, size_t in_len,
char *out, size_t out_sz, int *legacy) {
if (!in || !out || !out_sz) return NCM_ERR_ARG;
if (legacy) *legacy = 0;
if (in_len < SESSION_MAGIC_LEN + 16 || memcmp(in, SESSION_MAGIC, SESSION_MAGIC_LEN) != 0)
return NCM_ERR_CRYPTO;
unsigned char key[16];
if (session_key(SESSION_SALT, key) == 0) {
int rc = cookie_decrypt_key(in, in_len, key, out, out_sz);
if (rc == NCM_OK) return NCM_OK;
if (rc == NCM_ERR_FULL || rc == NCM_ERR_OOM) return rc;
}
if (ncm_store_machine_key(SESSION_SALT, key) == 0) {
int rc = cookie_decrypt_key(in, in_len, key, out, out_sz);
if (rc == NCM_OK) { if (legacy) *legacy = 1; return NCM_OK; }
}
return NCM_ERR_CRYPTO;
}
int ncm_store_cookie_decrypt(const unsigned char *in, size_t in_len,
char *out, size_t out_sz) {
return cookie_decrypt_sel(in, in_len, out, out_sz, NULL);
}
int ncm_store_save_session(const ncm_store_t *st, const char *cookie) { int ncm_store_save_session(const ncm_store_t *st, const char *cookie) {
if (!st || !cookie) return NCM_ERR_ARG; if (!st || !cookie) return NCM_ERR_ARG;
unsigned char enc[8192]; unsigned char enc[8192];
@@ -264,7 +310,11 @@ int ncm_store_load_session(const ncm_store_t *st, char *cookie, size_t sz) {
char raw[8192]; char raw[8192];
int n = ncm_store_read_file(path, raw, sizeof raw); int n = ncm_store_read_file(path, raw, sizeof raw);
if (n <= 0) return NCM_ERR_NOENT; if (n <= 0) return NCM_ERR_NOENT;
return ncm_store_cookie_decrypt((const unsigned char *)raw, (size_t)n, cookie, sz); int legacy = 0;
int rc = cookie_decrypt_sel((const unsigned char *)raw, (size_t)n, cookie, sz, &legacy);
/* 旧版 MAC 密钥解出 → 就近迁移为 .mkey 密钥(失败不回滚:本次仍返回明文会话)。 */
if (rc == NCM_OK && legacy) (void)ncm_store_save_session(st, cookie);
return rc;
} }
int ncm_store_clear_session(const ncm_store_t *st) { int ncm_store_clear_session(const ncm_store_t *st) {
+7 -3
View File
@@ -3,7 +3,8 @@
* 职责(纯 C,不依赖 UI/SDK): * 职责(纯 C,不依赖 UI/SDK):
* - 应用数据目录推断(env MEOW_DATA_DIR 优先,否则 /tmp 回退) * - 应用数据目录推断(env MEOW_DATA_DIR 优先,否则 /tmp 回退)
* - deviceId(52hex) 首生成 + 持久化(风控要求稳定) * - deviceId(52hex) 首生成 + 持久化(风控要求稳定)
* - 机器绑定密钥派生:sha256(MAC + 盐) → AES-128-CBC key(防裸读,非真安全) * - 设备私有密钥派生(<data_dir>/.mkey + 盐)→ AES-128-CBC key(防裸读,非真安全);
* 旧版 MAC 派生密钥保留兼容解密/自动迁移
* - 登录 cookie 加密落盘/读回(AES-128-CBC + 随机 IV + 魔数头) * - 登录 cookie 加密落盘/读回(AES-128-CBC + 随机 IV + 魔数头)
* - 原子写文件、读取、配置键读取(<dir>/<key>.cfg,对齐 SDK meow_config) * - 原子写文件、读取、配置键读取(<dir>/<key>.cfg,对齐 SDK meow_config)
*/ */
@@ -34,10 +35,13 @@ int ncm_store_get_device_id(ncm_store_t *st, char out[NCM_STORE_ID_MAX]);
int ncm_store_set_device_id(ncm_store_t *st, const char id[NCM_STORE_ID_MAX]); int ncm_store_set_device_id(ncm_store_t *st, const char id[NCM_STORE_ID_MAX]);
/* 机器绑定密钥:sha256(mac + salt) 取前 16 字节作为 AES-128 key。 /* 机器绑定密钥:sha256(mac + salt) 取前 16 字节作为 AES-128 key。
* mac 取首选网卡 MAC 小写;NCM_MAC 环境变量可覆盖(测试用)。失败返回非 0。 */ * mac 取首选网卡 MAC 小写;NCM_MAC 环境变量可覆盖(测试用)。失败返回非 0。
* ⚠ 仅作旧版 session.enc 的兼容解密(见下):MAC 随网卡出现/消失而变化,勿用于新写入。 */
int ncm_store_machine_key(const char *salt, unsigned char key[16]); int ncm_store_machine_key(const char *salt, unsigned char key[16]);
/* cookie 加解密(AES-128-CBC,PKCS7)。注意:这是防裸读模糊,非真安全。 */ /* cookie 加解密(AES-128-CBC,PKCS7)。注意:这是防裸读模糊,非真安全。
* M9 起密钥改为设备私有 <data_dir>/.mkey 派生(与网络/网卡状态无关);旧版 MAC 派生
* 密文仍可解,load_session 命中旧密钥时自动重写为新密钥(透明迁移)。 */
int ncm_store_cookie_encrypt(const char *plain, int ncm_store_cookie_encrypt(const char *plain,
unsigned char *out, size_t *out_len, size_t cap); unsigned char *out, size_t *out_len, size_t cap);
int ncm_store_cookie_decrypt(const unsigned char *in, size_t in_len, int ncm_store_cookie_decrypt(const unsigned char *in, size_t in_len,