From 7b32d775c0498cc4166743d123a767a40aaa5aef Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Thu, 12 Nov 2026 08:21:40 +0800
Subject: [PATCH 1/2] =?UTF-8?q?fix(security):=20M64=20P1=20=E4=B8=89?=
=?UTF-8?q?=E6=9D=A1=20root=20=E5=91=BD=E4=BB=A4=E6=B3=A8=E5=85=A5=20?=
=?UTF-8?q?=E2=80=94=20=E5=A4=96=E9=83=A8=E4=B8=B2=E6=94=B9=20execv=20?=
=?UTF-8?q?=E4=BC=A0=20argv=E3=80=81=E7=94=B5=E6=BA=90=E9=94=AE=E4=B8=8B?=
=?UTF-8?q?=E7=BA=BF=E3=80=81press=5Fms=20clamp?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
P1-1 Wi-Fi SSID/PSK 注入(netmgrd + launcherd 回退两处同步):
- 不再拼 `set_network %d ssid '"%s"'` 交给 sh -c;新增 wpa_exec()/nm_direct_wpa_exec()
以 fork+execv 传 argv(含 resolve_bin 解析绝对路径,替代 `PATH=...` 前缀)。
- 共用 nm_wifi_args.h:nm_wifi_ssid_ok/n_wifi_psk_ok/nm_wpa_quote/nm_wpa_set_argv,
两份实现同一套规则(只修一处等于没修)。
- 约束层:SSID 1..32B、禁控制字符;PSK ≤63 或 64 hex、禁控制字符;`"`/`\` 按
wpa_supplicant 语法转义。shell 元字符不再需要拒绝——execv 下没有 shell 解析。
P1-2 MeowPanel App 安装/导出/卸载注入:
- install/export/remove 改 run_argv()(fork+execv,PATH 只在子进程 setenv)。
- 包名统一 mp_pkg_ok()(仅 [a-z0-9._-],拒 `..`,与 nyai.c valid_pkg 同规则)→ 修 P2-7。
- `{path}` 追加 mp_path_unsafe()(与 mp_fonts/nyai 同一拒绝集)作第二道。
P1-3 /api/key/* 电源键:
- keycode_to_device 删除 KEY_POWER;116 进红线(403),前端遥控板电源/长按电源按钮移除。
- press_ms 全局 clamp 0..1000(单键与批量),`long` 缺省取上限。
- 按下→等待→抬起改独立线程(inject_key_async),不再阻塞单线程 8080 服务。
测试(宿主,反向控制):test_nm_wifi_args.c / test_mp_argsafe.c —— 同一套审计恶意串
(`a' ; id ; '`、`x';id;'`、`/tmp/a';id>/tmp/pwn;'.mpk`、`MeowAPPs/..`)在修复前实现下
注入成功(stdout 含 uid=)/放行,在修复后实现下不执行/拒绝。另含「两处实现同步」检查。
未刷机、未连设备、未发 /api/key 请求;只改源码,未提交二进制。
---
patch_v2/meow_launcherd/netmgr_ipc.c | 112 ++++++++++++-
patch_v2/meow_netmgrd/meow_netmgrd.c | 105 +++++++++++--
patch_v2/meow_netmgrd/nm_wifi_args.h | 108 +++++++++++++
patch_v2/meow_netmgrd/test_nm_wifi_args.c | 181 ++++++++++++++++++++++
patch_v2/meowpanel/README.md | 19 ++-
patch_v2/meowpanel/src/mp.h | 3 +-
patch_v2/meowpanel/src/mp_apps.c | 32 ++--
patch_v2/meowpanel/src/mp_argsafe.h | 63 ++++++++
patch_v2/meowpanel/src/mp_key.c | 98 ++++++++----
patch_v2/meowpanel/src/mp_util.c | 40 +++++
patch_v2/meowpanel/test_mp_argsafe.c | 115 ++++++++++++++
patch_v2/meowpanel/web/mp_pages.js | 7 +-
12 files changed, 812 insertions(+), 71 deletions(-)
create mode 100644 patch_v2/meow_netmgrd/nm_wifi_args.h
create mode 100644 patch_v2/meow_netmgrd/test_nm_wifi_args.c
create mode 100644 patch_v2/meowpanel/src/mp_argsafe.h
create mode 100644 patch_v2/meowpanel/test_mp_argsafe.c
diff --git a/patch_v2/meow_launcherd/netmgr_ipc.c b/patch_v2/meow_launcherd/netmgr_ipc.c
index c0edf16..011d21a 100644
--- a/patch_v2/meow_launcherd/netmgr_ipc.c
+++ b/patch_v2/meow_launcherd/netmgr_ipc.c
@@ -11,6 +11,9 @@
* 只读观测(wlan_connected()/hci_powered() 读 /sys 链路态)仍在 net.c;此处只接权威操作。
*/
#include "meow_launcher_internal.h"
+/* M64 P1-1:SSID/PSK 校验与 wpa 字面量转义。与 netmgrd 共用同一头文件——回退路径只修一处
+ * 等于没修(netmgrd 不可达时攻击者正是走这里),两边必须同一套规则。 */
+#include "../meow_netmgrd/nm_wifi_args.h"
#define NETMGR_SOCK_DEFAULT "/usr/data/meow_net.sock"
/* NETMGR_UNREACH 定义在 meow_launcher_internal.h(测试/调用方共用) */
@@ -424,6 +427,88 @@ static int nm_direct_wpa(char *out, int cap, const char *args, int tmo)
return l;
}
+/* ---- M64 P1-1:外部可控字符串(SSID/PSK)只走这一条,execv 传 argv,不经过 shell ----
+ * 修复前:nm_direct_wpa("set_network %d ssid '\"%s\"'", id, ssid) → popen(sh -c)。
+ * 恶意 AP 广播 SSID=`a' ; id ; '` 即可 root 执行。现在引号/分号/反引号只是 argv 里的字节。 */
+static int nm_wpa_bin(char *out, size_t cap)
+{
+ static const char *dirs[] = { "/sbin", "/usr/sbin", "/bin", "/usr/bin", NULL };
+ for (int i = 0; dirs[i]; i++) {
+ snprintf(out, cap, "%s/wpa_cli", dirs[i]);
+ if (access(out, X_OK) == 0) return 0;
+ }
+ return -1;
+}
+
+static long nm_mono_ms(void)
+{
+ struct timespec tp;
+ clock_gettime(CLOCK_MONOTONIC, &tp);
+ return tp.tv_sec * 1000L + tp.tv_nsec / 1000000L;
+}
+
+/* 执行 `wpa_cli -i wlan0 `(execv,无 shell)。args 以 NULL 结尾。
+ * 返回 stdout 字节数(-1=起不来),tmo 内有界收割,语义对齐 nm_direct_wpa。 */
+static int nm_direct_wpa_exec(char *out, int cap, char *const *args, int tmo)
+{
+ if (cap > 0) out[0] = 0;
+ char bin[128];
+ if (nm_wpa_bin(bin, sizeof bin) < 0) return -1;
+ char *argv[10];
+ int n = 0;
+ argv[n++] = bin;
+ argv[n++] = "-i";
+ argv[n++] = "wlan0";
+ for (int i = 0; args[i] && n < (int)(sizeof argv / sizeof argv[0]) - 1; i++) argv[n++] = args[i];
+ argv[n] = NULL;
+
+ int pfd[2];
+ if (pipe(pfd) < 0) return -1;
+ pid_t pid = fork();
+ if (pid < 0) { close(pfd[0]); close(pfd[1]); return -1; }
+ if (pid == 0) {
+ dup2(pfd[1], 1);
+ int dn = open("/dev/null", O_WRONLY);
+ if (dn >= 0) { dup2(dn, 2); close(dn); }
+ close(pfd[0]); close(pfd[1]);
+ execv(bin, argv);
+ _exit(127);
+ }
+ close(pfd[1]);
+ int off = 0, timed = 0;
+ long deadline = nm_mono_ms() + (tmo > 0 ? tmo : 1);
+ while (off < cap - 1) {
+ long remain = deadline - nm_mono_ms();
+ if (remain <= 0) { timed = 1; break; }
+ struct pollfd pf = { pfd[0], POLLIN, 0 };
+ int pr = poll(&pf, 1, (int)remain);
+ if (pr < 0) { if (errno == EINTR) continue; timed = 1; break; }
+ if (pr == 0) { timed = 1; break; }
+ ssize_t rd = read(pfd[0], out + off, (size_t)(cap - 1 - off));
+ if (rd <= 0) break;
+ off += (int)rd;
+ }
+ out[off] = 0;
+ close(pfd[0]);
+ if (timed) kill(pid, SIGKILL);
+ int st = 0;
+ waitpid(pid, &st, 0);
+ return off;
+}
+
+/* set_network <字符串值>:值按 wpa_supplicant 语法加引号(转义 `"`/`\`),
+ * 整条命令以 argv 传入。value 必须先过 nm_wifi_ssid_ok()/nm_wifi_psk_ok()。 */
+static int nm_direct_wpa_set(char *out, int cap, int id, const char *field, const char *val, int tmo)
+{
+ char idbuf[16], quoted[128];
+ char *args[5];
+ if (nm_wpa_set_argv(idbuf, sizeof idbuf, quoted, sizeof quoted, id, field, val, args) < 0) {
+ if (cap > 0) out[0] = 0;
+ return -1;
+ }
+ return nm_direct_wpa_exec(out, cap, args, tmo);
+}
+
static int nm_direct_save_config(void)
{
char out[128];
@@ -436,6 +521,15 @@ static int nm_direct_save_config(void)
static int nm_direct_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen)
{
char out[1024]; char args[256];
+ /* M64 P1-1:回退路径自己再校验一次(不依赖调用方;两条路径同一套规则)。 */
+ if (!nm_wifi_ssid_ok(ssid)) {
+ if (err) snprintf(err, elen, "ssid 非法(长度 1..32 字节,禁控制字符)");
+ return MEOW_ERR_INVAL;
+ }
+ if (!nm_wifi_psk_ok(psk)) {
+ if (err) snprintf(err, elen, "psk 非法(≤63 字符口令或 64 位 hex,禁控制字符)");
+ return MEOW_ERR_INVAL;
+ }
int id = -1;
if (nm_direct_wpa(out, sizeof out, "list_networks", 1500) > 0) {
char *ln = strtok(out, "\n");
@@ -472,13 +566,12 @@ static int nm_direct_connect_ex(const char *ssid, const char *psk, int hidden, c
id = atoi(out);
if (id < 0) { if (err) snprintf(err, elen, "add_network 无 id"); return MEOW_ERR_INVAL; }
created = 1;
- snprintf(args, sizeof args, "set_network %d ssid '\"%s\"'", id, ssid);
- nm_direct_wpa(out, sizeof out, args, 1500);
- if (psk && psk[0]) { snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk); nm_direct_wpa(out, sizeof out, args, 1500); }
+ /* M64 P1-1:ssid/psk 走 execv(nm_direct_wpa_set),不再拼 popen(sh -c)。 */
+ nm_direct_wpa_set(out, sizeof out, id, "ssid", ssid, 1500);
+ if (psk && psk[0]) { nm_direct_wpa_set(out, sizeof out, id, "psk", psk, 1500); }
else { snprintf(args, sizeof args, "set_network %d key_mgmt NONE", id); nm_direct_wpa(out, sizeof out, args, 1500); }
} else if (psk && psk[0]) {
- snprintf(args, sizeof args, "set_network %d psk '\"%s\"'", id, psk);
- nm_direct_wpa(out, sizeof out, args, 1500);
+ nm_direct_wpa_set(out, sizeof out, id, "psk", psk, 1500);
}
if (hidden) { snprintf(args, sizeof args, "set_network %d scan_ssid 1", id); nm_direct_wpa(out, sizeof out, args, 1500); }
snprintf(args, sizeof args, "enable_network %d", id); nm_direct_wpa(out, sizeof out, args, 1500);
@@ -519,6 +612,15 @@ static int nm_direct_connect_ex(const char *ssid, const char *psk, int hidden, c
int nm_wifi_connect_ex(const char *ssid, const char *psk, int hidden, char *err, int elen)
{
if (!ssid || !ssid[0]) { if (err) snprintf(err, elen, "ssid 缺失"); return MEOW_ERR_INVAL; }
+ /* M64 P1-1:IPC 路径与直连回退同一套校验(netmgrd 侧还会再校验一次)。 */
+ if (!nm_wifi_ssid_ok(ssid)) {
+ if (err) snprintf(err, elen, "ssid 非法(长度 1..32 字节,禁控制字符)");
+ return MEOW_ERR_INVAL;
+ }
+ if (!nm_wifi_psk_ok(psk)) {
+ if (err) snprintf(err, elen, "psk 非法(≤63 字符口令或 64 位 hex,禁控制字符)");
+ return MEOW_ERR_INVAL;
+ }
char arg[132];
snprintf(arg, sizeof arg, "%s,%s", ssid, psk ? psk : ""); /* 带空 psk → netmgrd 复用既有网络 */
char text[192];
diff --git a/patch_v2/meow_netmgrd/meow_netmgrd.c b/patch_v2/meow_netmgrd/meow_netmgrd.c
index bee91c1..630befe 100644
--- a/patch_v2/meow_netmgrd/meow_netmgrd.c
+++ b/patch_v2/meow_netmgrd/meow_netmgrd.c
@@ -51,6 +51,7 @@
#include
#include "meow_ipc.h" /* 帧格式/消息号 + MSG_NET_*(0x00E0-0x00EF) + m_net_*(号段 0x00E0-0x00EF) */
+#include "nm_wifi_args.h" /* M64 P1-1:SSID/PSK 参数校验 + wpa 字面量转义(与 launcherd 回退共用) */
/* ---------------- 路径(全部可经 env 覆盖,宿主/qemu 桩测试用) ---------------- */
static char g_net_dir[128] = "/sys/class/net"; /* MEOW_NET_DIR */
@@ -303,8 +304,10 @@ static int reap_bounded(pid_t pid, const char *cmd, int *st)
}
/* 带四态结果的执行器(res 可为 NULL,等价于旧 run_cmd)。返回 stdout 字节数(-1=起不来)。
+ * argv != NULL → execv(argv)(不经过 shell,M64 P1);argv == NULL → sh -c cmd(仅常量命令)。
* stdout 超容量时继续排空(丢弃),保证子进程不被 EPIPE/SIGPIPE 提前打断、退出码可信。 */
-static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_res_t *res)
+static int run_exec_ex(char *buf, int cap, int tmo_ms, char *const *argv, const char *cmd,
+ runcmd_res_t *res)
{
runcmd_res_t lr;
runcmd_res_t *r = res ? res : &lr;
@@ -312,6 +315,7 @@ static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_re
r->exit_code = -1;
r->status = RUNCMD_EXEC;
if (cap > 0) buf[0] = 0;
+ const char *label = argv ? argv[0] : cmd;
int op[2], ep[2];
if (pipe(op) < 0) return -1;
@@ -322,7 +326,11 @@ static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_re
dup2(op[1], 1);
dup2(ep[1], 2); /* M49:stderr 不再丢 /dev/null,失败时留一段进错误文案 */
close(op[0]); close(op[1]); close(ep[0]); close(ep[1]);
- execl("/bin/sh", "sh", "-c", cmd, (char *)0);
+ if (argv) {
+ execv(argv[0], argv);
+ } else {
+ execl("/bin/sh", "sh", "-c", cmd, (char *)0);
+ }
_exit(127);
}
close(op[1]); close(ep[1]);
@@ -370,7 +378,7 @@ static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_re
if (timed_out) kill(pid, SIGKILL); /* 已经超时:不再多等 300ms */
int st = 0;
- int reaped = reap_bounded(pid, cmd, &st);
+ int reaped = reap_bounded(pid, label, &st);
r->bytes = off;
r->truncated = trunc;
if (reaped) {
@@ -388,7 +396,19 @@ static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_re
/* 兼容外壳:返回读到的字节数(-1=起不来),语义与 P1 之前一致(调用点无需改动)。 */
static int run_cmd(char *buf, int cap, int tmo_ms, const char *cmd)
{
- return run_cmd_ex(buf, cap, tmo_ms, cmd, NULL);
+ return run_exec_ex(buf, cap, tmo_ms, NULL, cmd, NULL);
+}
+
+/* 带四态结果的兼容外壳(res 可为 NULL)。 */
+static int run_cmd_ex(char *buf, int cap, int tmo_ms, const char *cmd, runcmd_res_t *res)
+{
+ return run_exec_ex(buf, cap, tmo_ms, NULL, cmd, res);
+}
+
+/* argv 形态(M64 P1:外部可控字符串只走这里,永不拼 shell)。 */
+static int run_argv(char *buf, int cap, int tmo_ms, char *const *argv)
+{
+ return run_exec_ex(buf, cap, tmo_ms, argv, NULL, NULL);
}
/* 失败时的一句话原因(有界;stderr 有料就带上,最多 120B,避免报文过长)。 */
@@ -3147,7 +3167,27 @@ static const char *str_capsafe(const char *s, char *tmp, size_t cap)
return tmp;
}
-/* wpa_cli 单命令封装:拼 PATH 前缀 + 有界收割(RUNCMD_TMO_MS)。返回 run_cmd 读到的字节数。 */
+/* 在固件标准目录里解析可执行文件(等价旧 `PATH=/bin:/sbin:/usr/bin:/usr/sbin` 前缀,
+ * 但不再让 shell 去查 PATH)。g_* 可被 MEOW_* 环境变量覆盖为绝对路径(调试用)。 */
+static int resolve_bin(const char *name, char *out, size_t cap)
+{
+ if (!name || !name[0]) return -1;
+ if (strchr(name, '/')) {
+ if (access(name, X_OK) < 0) return -1;
+ snprintf(out, cap, "%s", name);
+ return 0;
+ }
+ static const char *dirs[] = { "/bin", "/sbin", "/usr/bin", "/usr/sbin", NULL };
+ for (int i = 0; dirs[i]; i++) {
+ snprintf(out, cap, "%s/%s", dirs[i], name);
+ if (access(out, X_OK) == 0) return 0;
+ }
+ return -1;
+}
+
+/* wpa_cli 单命令封装:拼 PATH 前缀 + 有界收割(RUNCMD_TMO_MS)。返回 run_cmd 读到的字节数。
+ * args 只允许**编译期常量**(scan/list_networks/status/...)。任何来自 IPC/用户/AP 广播的
+ * 字符串必须走 wpa_exec()(execv,无 shell)——M64 P1-1 之前正是这里把 SSID 拼进了 sh -c。 */
static int wpa_cmd(char *out, int cap, const char *args)
{
char cmd[512];
@@ -3155,6 +3195,34 @@ static int wpa_cmd(char *out, int cap, const char *args)
return run_cmd(out, cap, RUNCMD_TMO_MS, cmd);
}
+/* 执行 wpa_cli(execv 传 argv,不经过 shell)。args 以 NULL 结尾、不含 wpa_cli 本身。
+ * 返回 stdout 字节数(-1=二进制找不到/起不来)。
+ * `' ; ` $ & | < >` 在这里只是 argv 里的普通字节——这条路径上不存在 shell 解析。 */
+static int wpa_exec(char *out, int cap, char *const *args)
+{
+ char bin[256];
+ if (resolve_bin(g_wpa_cli, bin, sizeof bin) < 0) { if (cap > 0) out[0] = 0; return -1; }
+ char *argv[12];
+ int n = 0;
+ argv[n++] = bin;
+ for (int i = 0; args[i] && n < (int)(sizeof argv / sizeof argv[0]) - 1; i++) argv[n++] = args[i];
+ argv[n] = NULL;
+ return run_argv(out, cap, RUNCMD_TMO_MS, argv);
+}
+
+/* set_network <值>:字符串字段按 wpa_supplicant 语法加双引号(纯正确性转义,
+ * 与 shell 无关),整条命令以 argv 传入。val 必须先过 nm_wifi_ssid_ok/nm_wifi_psk_ok。 */
+static int wpa_set_string(char *out, int cap, int id, const char *field, const char *val)
+{
+ char idbuf[16], quoted[160];
+ char *args[5];
+ if (nm_wpa_set_argv(idbuf, sizeof idbuf, quoted, sizeof quoted, id, field, val, args) < 0) {
+ if (cap > 0) out[0] = 0;
+ return -1;
+ }
+ return wpa_exec(out, cap, args);
+}
+
/* WiFi 设置页 bug4-②(2026-09-15):list_networks/scan_results 对非 ASCII 字节输出 \xNN 转义
* (中文 SSID)——还原为原始字节串再与输入 SSID 做同表示比较;不还原则中文 SSID 永不命中
* 既有网络 → 新建同名 open 记录顶影原记录。net.c wpa_decode_ssid / netmgr_ipc.c nm_decode_ssid
@@ -3265,6 +3333,16 @@ static void wifi_connect_ex(const char *arg, ctl_result_t *r, int hidden)
str_capsafe(ssid, tmp_ssid, sizeof tmp_ssid);
str_capsafe(psk, tmp_psk, sizeof tmp_psk);
if (!tmp_ssid[0]) { ctl_sets(r, MEOW_ERR_INVAL, "connect: ssid 缺失"); return; }
+ /* M64 P1-1:SSID/PSK 是外部可控串(附近 AP 广播 / 用户输入)。长度与控制字符在这里
+ * 收口;执行侧已改 execv 传 argv(wpa_set_string),shell 元字符不再有语义。 */
+ if (!nm_wifi_ssid_ok(tmp_ssid)) {
+ ctl_sets(r, MEOW_ERR_INVAL, "connect: ssid 非法(长度 1..32 字节,禁控制字符)");
+ return;
+ }
+ if (!nm_wifi_psk_ok(tmp_psk)) {
+ ctl_sets(r, MEOW_ERR_INVAL, "connect: psk 非法(≤63 字符口令或 64 位 hex,禁控制字符)");
+ return;
+ }
/* 找既有网络:list_networks → 匹配 ssid(剥引号 + \xNN 还原后同表示比较);命中则复用其 id。 */
char cmd[256];
@@ -3304,16 +3382,19 @@ static void wifi_connect_ex(const char *arg, ctl_result_t *r, int hidden)
id = atoi(out);
if (id < 0) { ctl_sets(r, MEOW_ERR_INVAL, "connect: add_network 无 id"); return; }
created = 1;
- snprintf(cmd, sizeof cmd, "set_network %d ssid '\"%s\"'", id, tmp_ssid);
- wpa_cmd(out, sizeof out, cmd);
- if (tmp_psk[0]) snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk);
- else snprintf(cmd, sizeof cmd, "set_network %d key_mgmt NONE", id);
- wpa_cmd(out, sizeof out, cmd);
+ /* M64 P1-1:ssid/psk 走 execv 传 argv(wpa_set_string),不再拼 `sh -c`。
+ * 修复前是 `set_network %d ssid '\"%s\"'` —— 单引号里套双引号,SSID 带 `'` 即逸出。 */
+ wpa_set_string(out, sizeof out, id, "ssid", tmp_ssid);
+ if (tmp_psk[0]) {
+ wpa_set_string(out, sizeof out, id, "psk", tmp_psk);
+ } else {
+ snprintf(cmd, sizeof cmd, "set_network %d key_mgmt NONE", id);
+ wpa_cmd(out, sizeof out, cmd);
+ }
} else if (tmp_psk[0]) {
/* bug4-①/bug1-④:命中既有网络且调用方带了新 psk → 先更新凭据再连
* (旧记录密码错误可经扫描路径重输修复;psk 为空则不动既有凭据)。 */
- snprintf(cmd, sizeof cmd, "set_network %d psk '\"%s\"'", id, tmp_psk);
- wpa_cmd(out, sizeof out, cmd);
+ wpa_set_string(out, sizeof out, id, "psk", tmp_psk);
}
if (hidden) {
/* bug2:隐藏 AP 必须 scan_ssid=1,supplicant 才会主动发 Probe Request(命中既有也要补)。 */
diff --git a/patch_v2/meow_netmgrd/nm_wifi_args.h b/patch_v2/meow_netmgrd/nm_wifi_args.h
new file mode 100644
index 0000000..35c960e
--- /dev/null
+++ b/patch_v2/meow_netmgrd/nm_wifi_args.h
@@ -0,0 +1,108 @@
+/* nm_wifi_args.h — wpa_cli 参数安全(M64 P1-1:SSID/PSK → root 命令注入)。
+ *
+ * 修复前:SSID/PSK 被拼成 `set_network ssid '""'`,再交给
+ * `execl("/bin/sh","sh","-c",cmd)` 执行(meow_netmgrd.c:3307/3154/325;同一实现复制在
+ * meow_launcherd/netmgr_ipc.c:475 的直连回退)。单引号里套双引号,SSID 自己带 `'` 就能
+ * 闭合引号——附近恶意 AP 广播 SSID=`a' ; id ; '`,用户点连接即 root 执行。
+ *
+ * 修复分两层,本头只放「可在宿主上直接单测」的纯函数:
+ * 1) 彻底层:调用方改用 fork+execv 传 argv(见 meow_netmgrd.c:wpa_exec /
+ * netmgr_ipc.c:nm_direct_wpa_exec)。不再有 shell,`' ; ` $ & | < >` 全部只是
+ * 普通字节,既不需要也不敢拒绝("Bob's WiFi" 是合法 SSID)。
+ * 2) 约束层:SSID/PSK 按 802.11 与 wpa_supplicant 语法收口(长度、控制字符);
+ * 拼 wpa 字面量时转义 `"` 与 `\`——这两个字符破坏的是 supplicant 的引号解析,
+ * 与 shell 无关,属于正确性要求。
+ *
+ * 两份实现(netmgrd 与 launcherd 回退)共用本头:只修一处等于没修——launcherd 那条
+ * 路径正是 netmgrd 不可达时的回退,攻击者可以制造 netmgrd 不可达(M64 任务书 P1-1)。
+ */
+#ifndef NM_WIFI_ARGS_H
+#define NM_WIFI_ARGS_H
+
+#include
+#include
+#include
+
+#define NM_WIFI_SSID_MAX 32 /* 802.11 SSID 字节上限 */
+#define NM_WIFI_PSK_MAX 63 /* WPA passphrase 上限(8..63 字符);64 位 hex PSK 另算 */
+
+/* wpa_supplicant 的 ctrl_iface 是行协议:值里出现 \n/\r 会截断或伪造命令,
+ * 其它控制字符也进不了 SSID/PSK 的合法字符集。 */
+static inline int nm_wifi_ctl_free(const char *s, size_t n)
+{
+ for (size_t i = 0; i < n; i++) {
+ unsigned char c = (unsigned char)s[i];
+ if (c < 0x20 || c == 0x7f) return 0;
+ }
+ return 1;
+}
+
+static inline int nm_wifi_ssid_ok(const char *s)
+{
+ if (!s) return 0;
+ size_t n = strlen(s);
+ if (n == 0 || n > NM_WIFI_SSID_MAX) return 0;
+ return nm_wifi_ctl_free(s, n);
+}
+
+/* 空串 = 开放网络(调用方按 key_mgmt NONE 处理);≤63 字节口令;64 位 hex PSK。
+ * 不强制 8 字符下限:WPA 会自行拒绝过短口令,而面板/设置页对「密码错」的既有文案依赖
+ * 把它交给 supplicant 判定(M64 只收口注入面与行协议,不改失败语义)。 */
+static inline int nm_wifi_psk_ok(const char *s)
+{
+ if (!s || !s[0]) return 1;
+ size_t n = strlen(s);
+ if (!nm_wifi_ctl_free(s, n)) return 0;
+ if (n <= NM_WIFI_PSK_MAX) return 1;
+ if (n == 64) {
+ for (size_t i = 0; i < n; i++) {
+ char c = s[i];
+ if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')))
+ return 0;
+ }
+ return 1;
+ }
+ return 0;
+}
+
+/* 把值拼成 wpa_supplicant 的字面量 `"..."`(ssid/psk 这两个字段要求带引号)。
+ * 只转义 `"` 与 `\`——它们在 supplicant 的 parse_string 里有特殊含义。
+ * 返回 0 成功;-1 = 含控制字符或缓冲不足。 */
+static inline int nm_wpa_quote(const char *val, char *out, size_t cap)
+{
+ if (!val || !out) return -1;
+ size_t n = strlen(val);
+ if (!nm_wifi_ctl_free(val, n)) return -1;
+ size_t need = 3; /* 开引号 + 闭引号 + NUL */
+ for (size_t i = 0; i < n; i++) need += (val[i] == '"' || val[i] == '\\') ? 2 : 1;
+ if (need > cap) return -1;
+ size_t o = 0;
+ out[o++] = '"';
+ for (size_t i = 0; i < n; i++) {
+ char c = val[i];
+ if (c == '"' || c == '\\') out[o++] = '\\';
+ out[o++] = c;
+ }
+ out[o++] = '"';
+ out[o] = 0;
+ return 0;
+}
+
+/* 组装 `set_network <"value">` 的 argv(argv[0] 由调用方补 wpa_cli 路径)。
+ * 生产代码(netmgrd wpa_set_string / launcherd nm_direct_wpa_set)与宿主测试共用这一份,
+ * 保证测试断言的就是实际下发的那条命令。返回 0 成功;-1 = 值不合法/缓冲不足。 */
+static inline int nm_wpa_set_argv(char *idbuf, size_t idcap, char *quoted, size_t qcap,
+ int id, const char *field, const char *val, char *argv[5])
+{
+ if (!field || !argv) return -1;
+ if (nm_wpa_quote(val, quoted, qcap) < 0) return -1;
+ snprintf(idbuf, idcap, "%d", id);
+ argv[0] = (char *)"set_network";
+ argv[1] = idbuf;
+ argv[2] = (char *)field;
+ argv[3] = quoted;
+ argv[4] = NULL;
+ return 0;
+}
+
+#endif /* NM_WIFI_ARGS_H */
diff --git a/patch_v2/meow_netmgrd/test_nm_wifi_args.c b/patch_v2/meow_netmgrd/test_nm_wifi_args.c
new file mode 100644
index 0000000..24c4ef5
--- /dev/null
+++ b/patch_v2/meow_netmgrd/test_nm_wifi_args.c
@@ -0,0 +1,181 @@
+/* test_nm_wifi_args.c — M64 P1-1 宿主回归:SSID/PSK 参数安全 + 反向控制。
+ *
+ * 纯宿主测试:不连设备、不启服务、不发 /api/key/,只用本进程 + sh + execv。
+ *
+ * 编译运行(在 patch_v2/meow_netmgrd 下):
+ * cc -O1 -Wall -Wextra -I. test_nm_wifi_args.c -o ~/tmp/test_nm_wifi_args
+ * ~/tmp/test_nm_wifi_args
+ *
+ * 反向控制:同一套用例分别喂给
+ * ① 「修复前实现」复刻(legacy_build:`set_network %d ssid '"%s"'` → sh -c)→ 必须注入成功;
+ * ② 修复后实现(nm_wifi_ssid_ok/nm_wpa_set_argv + execv 传 argv)→ 必须不执行任何 shell。
+ * 只有 ①成立、②不成立,才证明这套改动真的拦住了东西(而不是「本来就不会中」)。
+ */
+#include
+#include
+#include
+#include
+#include
+
+#include "nm_wifi_args.h"
+
+static char g_self[512];
+static int g_fail;
+
+#define CHECK(cond, ...) do { \
+ if (!(cond)) { printf(" FAIL: "); printf(__VA_ARGS__); printf("\n"); g_fail = 1; } \
+ else { printf(" PASS: "); printf(__VA_ARGS__); printf("\n"); } \
+ } while (0)
+
+/* 复刻修复前 meow_netmgrd.c:3154 + :3307 的拼法:SSID 塞进单引号内的双引号,整行交给 sh -c。
+ * (值是 `a' ; id ; '` 时,第一个 `'` 就把单引号闭合了。) */
+static void legacy_build(const char *field, const char *val, char *out, size_t cap)
+{
+ char args[256];
+ snprintf(args, sizeof args, "set_network 0 %s '\"%s\"'", field, val);
+ snprintf(out, cap, "PATH=/bin:/sbin:/usr/bin:/usr/sbin wpa_cli %s 2>/dev/null", args);
+}
+
+static int run_shell(const char *cmd, char *out, int cap)
+{
+ out[0] = 0;
+ FILE *f = popen(cmd, "r");
+ if (!f) return -1;
+ size_t off = fread(out, 1, (size_t)cap - 1, f);
+ out[off] = 0;
+ return pclose(f);
+}
+
+/* 用 execv 跑一条 argv,子进程是本程序的 --stub-argv 形态(把收到的 argv 原样打印)。
+ * 这就是修复后的下发方式:wpa_cli 收到的是 argv,不经 shell。 */
+static int run_argv_stub(char *const *args, char *out, int cap)
+{
+ out[0] = 0;
+ int pfd[2];
+ if (pipe(pfd) < 0) return -1;
+ pid_t pid = fork();
+ if (pid < 0) { close(pfd[0]); close(pfd[1]); return -1; }
+ if (pid == 0) {
+ dup2(pfd[1], 1);
+ dup2(pfd[1], 2);
+ close(pfd[0]); close(pfd[1]);
+ char *av[10];
+ int n = 0;
+ av[n++] = (char *)"test_nm_wifi_args";
+ av[n++] = (char *)"--stub-argv";
+ for (int i = 0; args[i] && n < 9; i++) av[n++] = args[i];
+ av[n] = NULL;
+ execv(g_self, av);
+ _exit(127);
+ }
+ close(pfd[1]);
+ size_t off = 0;
+ for (;;) {
+ size_t room = (off + 1 < (size_t)cap) ? (size_t)cap - 1 - off : 0;
+ if (!room) break;
+ ssize_t rd = read(pfd[0], out + off, room);
+ if (rd <= 0) break;
+ off += (size_t)rd;
+ }
+ out[off] = 0;
+ close(pfd[0]);
+ int st = 0;
+ waitpid(pid, &st, 0);
+ return st;
+}
+
+/* 旧实现「接受」判定:str_capsafe(meow_netmgrd.c:3140)只 trim 空白,不校验字符。 */
+static int legacy_ssid_accepted(const char *s)
+{
+ return s && s[0];
+}
+
+/* 一条用例的双跑:旧实现必须中招,新实现必须只把值当字节。 */
+static void case_attack(const char *label, const char *field, const char *val)
+{
+ char cmd[512], out[4096];
+ printf("== %s ==\n value=%s\n", label, val);
+
+ /* ① 反向控制:修复前 */
+ CHECK(legacy_ssid_accepted(val), "修复前判定放行该值(无字符校验)");
+ legacy_build(field, val, cmd, sizeof cmd);
+ printf(" 修复前命令:%s\n", cmd);
+ run_shell(cmd, out, sizeof out);
+ CHECK(strstr(out, "uid=") != NULL, "修复前被注入:值里的 `id` 在 root 语境执行(stdout 含 uid=)");
+
+ /* ② 修复后 */
+ char idbuf[16], quoted[160];
+ char *args[5];
+ int rc = nm_wpa_set_argv(idbuf, sizeof idbuf, quoted, sizeof quoted, 0, field, val, args);
+ CHECK(rc == 0, "修复后可构造 argv(execv 传参,值里的元字符无 shell 语义)");
+ char *full[7] = { (char *)"wpa_cli", args[0], args[1], args[2], args[3], NULL };
+ run_argv_stub(full, out, sizeof out);
+ printf(" 修复后子进程收到的 argv:\n%s", out);
+ CHECK(strstr(out, "uid=") == NULL, "修复后没有 shell:值里的 `id` 不执行");
+ CHECK(strstr(out, quoted) != NULL, "值原样作为**单个** argv 元素到达(未断词)");
+}
+
+int main(int argc, char **argv)
+{
+ if (argc > 1 && !strcmp(argv[1], "--stub-argv")) { /* execv 桩:打印收到的 argv */
+ for (int i = 2; i < argc; i++) printf("ARG[%d]=%s\n", i - 2, argv[i]);
+ return 0;
+ }
+ ssize_t l = readlink("/proc/self/exe", g_self, sizeof g_self - 1);
+ if (l <= 0) snprintf(g_self, sizeof g_self, "%s", argv[0]);
+ else g_self[l] = 0;
+
+ printf("== M64 P1-1 反向控制:修复前注入 / 修复后不注入 ==\n");
+ /* 审计给出的真实恶意串:附近 AP 广播的 SSID 与用户输入的 PSK。 */
+ case_attack("SSID 注入(审计原串)", "ssid", "a' ; id ; '");
+ case_attack("PSK 注入(审计原串)", "psk", "x';id;'");
+
+ printf("== 约束层:长度 / 控制字符 ==\n");
+ CHECK(nm_wifi_ssid_ok("a' ; id ; '") == 1, "`a' ; id ; '` 通过校验(11B<32B,是合法 SSID;靠 execv 中立化)");
+ CHECK(nm_wifi_ssid_ok("Home2G") == 1, "普通 SSID 通过");
+ CHECK(nm_wifi_ssid_ok("中文网络") == 1, "UTF-8 中文 SSID 通过(12B)");
+ CHECK(nm_wifi_ssid_ok("") == 0, "空 SSID 拒绝");
+ CHECK(nm_wifi_ssid_ok("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") == 0, "33 字节 SSID 拒绝(802.11 上限 32)");
+ CHECK(nm_wifi_ssid_ok("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") == 1, "32 字节 SSID 通过");
+ CHECK(nm_wifi_ssid_ok("a\nset_network 0 psk \"x\"") == 0, "含 \\n 的 SSID 拒绝(wpa ctrl_iface 行协议)");
+ CHECK(nm_wifi_ssid_ok("a\x7f" "b") == 0, "含 DEL 的 SSID 拒绝");
+ CHECK(nm_wifi_ssid_ok("a\tb") == 0, "含 TAB 的 SSID 拒绝");
+
+ CHECK(nm_wifi_psk_ok("") == 1, "空 PSK(开放网络)通过");
+ CHECK(nm_wifi_psk_ok("pass1234") == 1, "普通口令通过");
+ CHECK(nm_wifi_psk_ok("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") == 1,
+ "64 位 hex PSK 通过");
+ CHECK(nm_wifi_psk_ok("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz") == 0,
+ "64 字符非 hex 拒绝(≠ 合法 PSK)");
+ CHECK(nm_wifi_psk_ok("x';id;'") == 1, "`x';id;'` 通过(=合法口令字节,靠 execv 中立化)");
+ CHECK(nm_wifi_psk_ok("a\nb") == 0, "含 \\n 的 PSK 拒绝");
+
+ printf("== wpa_supplicant 字面量:只转义 \" 与 \\ ==\n");
+ {
+ char q[64];
+ CHECK(nm_wpa_quote("a\"b\\c", q, sizeof q) == 0 && !strcmp(q, "\"a\\\"b\\\\c\""),
+ "`a\"b\\c` → %s", q);
+ CHECK(nm_wpa_quote("a' ; id ; '", q, sizeof q) == 0 && !strcmp(q, "\"a' ; id ; '\""),
+ "shell 元字符原样保留 → %s", q);
+ CHECK(nm_wpa_quote("a\nb", q, sizeof q) == -1, "控制字符拒绝(不落盘、不进 ctrl_iface)");
+ CHECK(nm_wpa_quote("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", q, 8) == -1, "缓冲不足返回 -1");
+ }
+
+ printf("== 两处实现同步(只修一处等于没修)==\n");
+ {
+ static const char *files[] = { "meow_netmgrd.c", "../meow_launcherd/netmgr_ipc.c" };
+ for (int i = 0; i < 2; i++) {
+ FILE *f = fopen(files[i], "r");
+ if (!f) { printf(" SKIP: 读不到 %s(从 patch_v2/meow_netmgrd 下运行才检查)\n", files[i]); continue; }
+ char buf[400000];
+ size_t n = fread(buf, 1, sizeof buf - 1, f);
+ buf[n] = 0;
+ fclose(f);
+ CHECK(strstr(buf, "nm_wpa_set_argv") != NULL, "%s 使用共享的 nm_wpa_set_argv", files[i]);
+ CHECK(strstr(buf, "ssid '\"") == NULL, "%s 不再有 `ssid '\\\"` 拼法", files[i]);
+ }
+ }
+
+ printf(g_fail ? "\nRESULT: FAIL\n" : "\nRESULT: ALL PASS\n");
+ return g_fail ? 1 : 0;
+}
diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md
index c6c4fa4..f3b2a67 100644
--- a/patch_v2/meowpanel/README.md
+++ b/patch_v2/meowpanel/README.md
@@ -142,7 +142,7 @@ http://.local:8080/
| POST | `/api/apps/install` | 经 `nyai install`(multipart 或 path) | ✅ |
| GET | `/api/apps/:pkg/export` | `nyai export` 下载流 | ✅ |
| DELETE | `/api/apps/:pkg` | `nyai remove [--purge]` | ✅ |
-| POST | `/api/key/:code` | 按键注入(tap/long) | ✅(物理键红线内) |
+| POST | `/api/key/:code` | 按键注入(tap/long;`ms` clamp 0..1000;电源 116 已下线) | ✅(物理键红线内) |
| POST | `/api/key` | 批量/顺序注入 | ✅ |
| GET | `/api/logs` | `MeowOS/logs` 文件列表(每文件含 `group` 来源分组) | ✅(无卡→503) |
| GET | `/api/logs/crash` | **崩溃汇总**:解析最新 `crash_.log`(meow_crash 落盘格式)→ 每实体崩溃数 + 最近崩溃时间/信号 + dmesg 计数 | ✅(无卡→503;无 crash 日志→空汇总) |
@@ -218,8 +218,11 @@ http://.local:8080/
### 5.5 nyai 对接
- 包管理器 CLI 绝对路径 `/usr/data/mnt/sd_0/MeowOS/opt/bin/nyai`(M2.12+ 固件经 `meow_card_path.sh` 前插 PATH,可直接调;
本后端用绝对路径,兼容更早固件)。包名 = `app.json` 的 `package` = `.mpk` 文件名茎。
-- `install`:multipart 上传 `.mpk` 落临时文件(`MeowOS/tmp/`)或 JSON `{path}` → `nyai install `;用 `popen` 捕获输出+退出码,
+- `install`:multipart 上传 `.mpk` 落临时文件(`MeowOS/tmp/`)或 JSON `{path}` → `nyai install `;用 `fork`+`execv`
+ 传 argv 捕获输出+退出码(不经 shell,M64 P1-2:修复前是 `popen("... '' install ''")`,路径里一个 `'` 即 root 执行),
非 0 → 422。`export`:`nyai export ` → 以 `Content-Disposition: attachment` 流式下发。`remove`:`nyai remove [--purge]`。
+- **参数校验**(M64 P1-2 / P2-7):包名统一 `mp_pkg_ok()`(仅 `[a-z0-9._-]`,拒 `..`/前导尾点,与 nyai.c `valid_pkg()` 同规则)
+ → 非法 → 400;`{path}` 再按 `mp_path_unsafe()`(与 mp_fonts/nyai 同一拒绝集)挡一道,防调用被改回 shell 拼接。
- `GET /api/apps` / `:pkg` 详情:直接扫描 `MeowAPPs//app.json`(手写 JSON 解析提取 name/version/exec/framework/immersive/fullscreen),
不依赖 nyai(nyai 未部署时列表/详情仍可用)。解析失败项 `invalid:true`。
- `nyai` 未部署 → install/export/remove 返回 500(带 nyai 输出)。
@@ -228,9 +231,15 @@ http://.local:8080/
- **复用现有先例**:`patch_v2/uinput_tap/uinput_tap.c` 的 `write`-到-evdev 注入机制。launcher 实际轮询
`meow_launcher_internal.h`:`/dev/input/event0`(md-gpio-keys: POWER/PREVIOUS)、`/dev/input/event2`
(jz adc keyboard: VOL/PLAY/NEXT)。后端 open `O_WRONLY` 写 `EV_KEY press/release + SYN_REPORT` 即可到达 launcher 按键处理。
-- **物理键红线**(APPS.md / 任务约束):只注入音量(114/115)/电源(116)/播放(164)/上下曲(163/165);
- **禁止注入 返回(158)/主页(172)/确认(28)/退出(96)** → 403 `FORBIDDEN`。
-- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 3000);`hold` → 501。
+- **物理键红线**(APPS.md / 任务约束 + M64 P1-3):只注入音量(114/115)/播放(164)/上下曲(163/165);
+ **禁止注入 返回(158)/主页(172)/确认(28)/退出(96)/电源(116)** → 403 `FORBIDDEN`。
+ 电源键(`/dev/input/event0` 的 KEY_POWER)已从 `keycode_to_device` 移除:设备端
+ `power.forceoff_ms` 缺省 8000ms 且**从按下起算**,注入它等于绕过 `/api/system/shutdown` 的二次确认
+ (一条 `{"press":"long","ms":8000}` 即强制关机)。电源/关机只走 `/api/system/shutdown`(前端有确认弹窗)。
+- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 1000);`hold` → 501。
+ **`ms` 全局 clamp 到 0..1000**(`mp_key_clamp_ms`),单键与批量同一上限。
+- 注入在**独立线程**里做按下→等待→抬起(`inject_key_async`):修复前是在请求线程 `usleep(ms)`,
+ 一个键请求就把单线程 8080 服务整段阻塞。设备 fd 仍由请求线程同步 open,打不开 → 500。
- 局限:若 `/dev/input/event0/event2` 在设备上不可写(权限/路径差异),注入失败 → 500;注入为**即时性注入**,
不在 launcher 内维护长按状态机(与实体按键的 `longpressed` 语义近似,见 README 已知边界)。
diff --git a/patch_v2/meowpanel/src/mp.h b/patch_v2/meowpanel/src/mp.h
index 041ce06..ddbdd9a 100644
--- a/patch_v2/meowpanel/src/mp.h
+++ b/patch_v2/meowpanel/src/mp.h
@@ -323,7 +323,7 @@ int files_list(int sock, const char *relpath);
char *read_text_file_limited(const char *abs, long long *size, int *truncated);
int keycode_to_device(int code, const char **dev);
int inject_key_event(const char *dev, int code, int down);
-int inject_key_release(int code, int press_ms);
+int inject_key_async(int code, int press_ms); /* M64 P1-3:释放放独立线程,不阻塞 panel */
int read_small_file(const char *path, char *buf, int cap);
int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail);
void req_free(req_t *r);
@@ -436,6 +436,7 @@ int sha256_file_hex(const char *path, char out[65]);
int api_fonts_import(int sock, const req_t *r);
int api_fonts_export(int sock, const req_t *r);
int run_cmd(const char *cmd, char *out, int outcap);
+int run_argv(char *out, int outcap, const char *const argv[]); /* M64 P1-2:execv 形态,无 shell */
int api_apps_list(int sock);
int api_apps_info(int sock, const char *pkg);
int multipart_extract_file(const req_t *r, const char *outpath);
diff --git a/patch_v2/meowpanel/src/mp_apps.c b/patch_v2/meowpanel/src/mp_apps.c
index 4a52a13..83df280 100644
--- a/patch_v2/meowpanel/src/mp_apps.c
+++ b/patch_v2/meowpanel/src/mp_apps.c
@@ -1,5 +1,6 @@
/* mp_apps.c — 应用域:MeowAPPs 扫描 + nyai 安装/导出/移除。 */
#include "mp.h"
+#include "mp_argsafe.h" /* M64 P1-2:包名/路径判定(与 nyai.c:125/137 同规则) */
int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail)
@@ -115,7 +116,8 @@ int api_apps_list(int sock)
int api_apps_info(int sock, const char *pkg)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
- if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
+ /* M64 P1-2 / P2-7:旧判定只拒 `/` 与 `:`,`..` 与 `x';id;'` 都能过。 */
+ if (!mp_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char appdir[1024];
snprintf(appdir, sizeof appdir, "%s/%s", MEOWAPPS_DIR, pkg);
struct stat st;
@@ -164,10 +166,12 @@ int api_apps_install(int sock, const req_t *r)
snprintf(mpkpath, sizeof mpkpath, "%s", tmp);
}
if (access(mpkpath, R_OK) < 0) return send_err(sock, 404, "NOT_FOUND", ".mpk 文件不存在");
- char cmd[1400];
- snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' install '%s' 2>&1", nyai_bin(), mpkpath);
+ /* M64 P1-2:`{path}` 是外部可控串(旧实现直接塞进 popen 的 shell 行)。execv 传入后
+ * 元字符已无语义;这里仍按 nyai/mp_fonts 同一拒绝集挡住,防调用被改回 run_cmd。 */
+ if (mp_path_unsafe(mpkpath)) return send_err(sock, 400, "BAD_REQUEST", "路径含非法字符");
char out[4096];
- int code = run_cmd(cmd, out, sizeof out);
+ const char *argv[] = { nyai_bin(), "install", mpkpath, NULL };
+ int code = run_argv(out, sizeof out, argv);
if (code != 0) return send_err(sock, 422, "UNSUPPORTED", out[0] ? out : "nyai install 失败");
/* 包名 = 文件名茎;版本从 app.json 读 */
char stem[256];
@@ -205,17 +209,16 @@ int api_apps_install(int sock, const req_t *r)
int api_apps_export(int sock, const char *pkg)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
- if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
+ if (!mp_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char tmp[1100];
char card_tmp[1200];
snprintf(card_tmp, sizeof card_tmp, "%s/tmp", TF_CARD_DIR);
mkdir(card_tmp, 0777);
snprintf(tmp, sizeof tmp, "%s/tmp/%s.mpk", TF_CARD_DIR, pkg);
unlink(tmp);
- char cmd[1400];
- snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' export '%s' '%s' 2>&1", nyai_bin(), pkg, tmp);
char out[1024];
- int code = run_cmd(cmd, out, sizeof out);
+ const char *argv[] = { nyai_bin(), "export", pkg, tmp, NULL };
+ int code = run_argv(out, sizeof out, argv);
if (code != 0 || access(tmp, R_OK) < 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai export 失败");
char dn[256];
snprintf(dn, sizeof dn, "%s.mpk", pkg);
@@ -225,14 +228,19 @@ int api_apps_export(int sock, const char *pkg)
int api_apps_remove(int sock, const char *pkg, const req_t *r)
{
if (!tf_present()) return send_err(sock, 503, "NO_CARD", "未识别到 TF 卡");
- if (strchr(pkg, '/') || strchr(pkg, ':')) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
+ if (!mp_pkg_ok(pkg)) return send_err(sock, 400, "BAD_REQUEST", "包名非法");
char purgebuf[8] = "0";
if (r->query[0]) qparam(r->query, "purge", purgebuf, sizeof purgebuf);
int use_purge = atoi(purgebuf);
- char cmd[1400];
- snprintf(cmd, sizeof cmd, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' remove '%s'%s 2>&1", nyai_bin(), pkg, use_purge ? " --purge" : "");
char out[1024];
- int code = run_cmd(cmd, out, sizeof out);
+ const char *argv[6];
+ int n = 0;
+ argv[n++] = nyai_bin();
+ argv[n++] = "remove";
+ argv[n++] = pkg;
+ if (use_purge) argv[n++] = "--purge";
+ argv[n] = NULL;
+ int code = run_argv(out, sizeof out, argv);
if (code != 0) return send_err(sock, 500, "INTERNAL", out[0] ? out : "nyai remove 失败");
sbuf_t s = {0};
sA(&s, "{\"removed\":true,\"purged\":%s}", use_purge ? "true" : "false");
diff --git a/patch_v2/meowpanel/src/mp_argsafe.h b/patch_v2/meowpanel/src/mp_argsafe.h
new file mode 100644
index 0000000..da8576f
--- /dev/null
+++ b/patch_v2/meowpanel/src/mp_argsafe.h
@@ -0,0 +1,63 @@
+/* mp_argsafe.h — MeowPanel 参数安全(M64 P1-2 / P1-3 纯函数,宿主可直接单测)。
+ *
+ * 只放「无副作用、不依赖 panel 运行态」的判定函数:这样 test_mp_argsafe.c 能把它和
+ * 反向控制(修复前的等价判定)跑在同一套用例上,证明校验真的拦住了东西。
+ *
+ * P1-2:App 安装/导出/卸载把外部串拼进 `PATH=... '' install ''` 再 popen。
+ * 修复:调用侧改 execv 传 argv(mp_util.c:run_argv),元字符失去 shell 语义;名字类
+ * 参数(包名)仍按结构拒绝——包名本就只有 [a-z0-9._-],没有兼容代价。
+ * P1-3:/api/key/ 的 press_ms 无上下限(一条 `{"press":"tap","ms":8000}` 即强制关机)。
+ * 修复:电源键从 keycode_to_device 移除 + press_ms 全局 clamp。
+ */
+#ifndef MP_ARGSAFE_H
+#define MP_ARGSAFE_H
+
+#include
+#include
+
+/* 包名合法:仅 [a-z0-9._-],拒绝 "." / ".." / 前导或尾随 '.'。
+ * 规则与 nyai.c:125 valid_pkg() 完全一致(两个二进制各自的私有实现,靠同一份规则收敛):
+ * 只收小写是安全要求——exFAT 名字比较不区分大小写,放过大写就能用 "OS.MEOW.READER"
+ * 落到已装的 MeowAPPs/os.meow.reader 上并绕过 is_system_pkg() 的签名门。
+ * 同时修掉 P2-7:旧判定只拒 `/` 与 `:`,`MeowAPPs/..` 与 `x';id;'` 都能过。 */
+static inline int mp_pkg_ok(const char *s)
+{
+ if (!s || !*s) return 0;
+ if (s[0] == '.') return 0; /* "." / ".." / 隐藏名 */
+ size_t n = strlen(s);
+ if (s[n - 1] == '.') return 0; /* exFAT/Windows 尾点映射怪癖 */
+ for (; *s; s++) {
+ unsigned char c = (unsigned char)*s;
+ if (!((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
+ c == '.' || c == '-' || c == '_')) return 0;
+ }
+ return 1;
+}
+
+/* 路径/参数里不许出现的字符(拒绝集与 mp_fonts.c:22 unsafe_path、nyai.c:137 一致)。
+ * execv 已让它们不具备 shell 语义;这里保留为第二道,防后人把调用改回 popen/run_cmd。 */
+static inline int mp_path_unsafe(const char *s)
+{
+ return s && strpbrk(s, " ;`$'\"&|<>") != NULL;
+}
+
+/* /api/key/ 按压时长上限(ms)。设备端 power.forceoff_ms 缺省 8000ms、从按下起算,
+ * 修复前 ms 无上限 → 一条请求即强制关机(用户已被关机两次)。 */
+#define MP_KEY_PRESS_MS_MAX 1000
+
+static inline int mp_key_clamp_ms(int ms)
+{
+ if (ms < 0) return 0;
+ if (ms > MP_KEY_PRESS_MS_MAX) return MP_KEY_PRESS_MS_MAX;
+ return ms;
+}
+
+/* 禁止注入的键码(APPS.md 物理键红线 + M64 P1-3 电源键)。
+ * 116=KEY_POWER:设备端强制关机计时从按下起算,注入它等于绕过 /api/system/shutdown 的
+ * 二次确认(用户已因此被关机两次)。修复前这里只有 158/172/32/28/96。 */
+static inline int mp_key_forbidden(int code)
+{
+ return code == 116 || code == 158 || code == 172 || code == 32 || code == 28 || code == 96;
+}
+
+#endif /* MP_ARGSAFE_H */
diff --git a/patch_v2/meowpanel/src/mp_key.c b/patch_v2/meowpanel/src/mp_key.c
index cedea86..f6bbb0a 100644
--- a/patch_v2/meowpanel/src/mp_key.c
+++ b/patch_v2/meowpanel/src/mp_key.c
@@ -1,14 +1,20 @@
/* mp_key.c — 远程控制域:按键注入(写 /dev/input/event* EV_KEY,物理键红线内)。 */
#include "mp.h"
+#include "mp_argsafe.h" /* M64 P1-3:press_ms 上限 */
+#include /* 按键释放放独立线程:不再用 usleep 阻塞单线程 panel */
#define PWR_KEYDEV "/dev/input/event0"
#define ADC_KEYDEV "/dev/input/event2"
+/* M64 P1-3:电源键(116)不再可注入。
+ * 修复前 KEY_POWER → /dev/input/event0,而设备端 power.forceoff_ms 缺省 8000ms 且**从按下
+ * 起算**——一条 `{"press":"long","ms":8000}` 即强制关机,绕过 /api/system/shutdown 的前端
+ * 二次确认(用户已因此被关机两次)。电源只走 /api/system/shutdown(带确认弹窗)。
+ * 前端 mp_pages.js 的遥控板电源/长按电源按钮已一并移除。 */
int keycode_to_device(int code, const char **dev)
{
switch (code) {
- case KEY_POWER: *dev = PWR_KEYDEV; return KEY_POWER;
case KEY_PREVIOUSSONG: *dev = PWR_KEYDEV; return KEY_PREVIOUSSONG;
case KEY_VOLUMEUP: *dev = ADC_KEYDEV; return KEY_VOLUMEUP;
case KEY_VOLUMEDOWN: *dev = ADC_KEYDEV; return KEY_VOLUMEDOWN;
@@ -18,37 +24,63 @@ int keycode_to_device(int code, const char **dev)
}
}
-int inject_key_event(const char *dev, int code, int down)
+static int write_key_fd(int fd, int code, int down)
{
- int fd = open(dev, O_WRONLY | O_NONBLOCK);
- if (fd < 0) return -1;
struct input_event ev;
memset(&ev, 0, sizeof ev);
ev.type = EV_KEY;
ev.code = code;
ev.value = down ? 1 : 0;
- int ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev);
- if (ok) {
- memset(&ev, 0, sizeof ev);
- ev.type = EV_SYN;
- ev.code = SYN_REPORT;
- ev.value = 0;
- ok = (write(fd, &ev, sizeof ev) == (ssize_t)sizeof ev);
- }
- close(fd);
- return ok ? 0 : -1;
+ if (write(fd, &ev, sizeof ev) != (ssize_t)sizeof ev) return -1;
+ memset(&ev, 0, sizeof ev);
+ ev.type = EV_SYN;
+ ev.code = SYN_REPORT;
+ ev.value = 0;
+ if (write(fd, &ev, sizeof ev) != (ssize_t)sizeof ev) return -1;
+ return 0;
}
-int inject_key_release(int code, int press_ms)
+int inject_key_event(const char *dev, int code, int down)
+{
+ int fd = open(dev, O_WRONLY | O_NONBLOCK);
+ if (fd < 0) return -1;
+ int ok = write_key_fd(fd, code, down);
+ close(fd);
+ return ok;
+}
+
+typedef struct { int fd; int code; int press_ms; } keyjob_t;
+
+/* 按下→等→松开在独立线程里做。修复前是请求线程里 usleep(press_ms)(还无上限):
+ * 一个键请求就把单线程 8080 服务整段阻塞住。press_ms 已 clamp 到 ≤1000ms,
+ * 线程是 drop 型(detach,自带 job 内存与 fd),请求线程立即返回。
+ * 设备 fd 仍由请求线程同步 open(失败立刻如实报 500,保住原有错误语义)。 */
+static void *keyjob_run(void *arg)
+{
+ keyjob_t *j = arg;
+ if (write_key_fd(j->fd, j->code, 1) == 0) {
+ usleep((useconds_t)(j->press_ms > 0 ? j->press_ms : 80) * 1000);
+ write_key_fd(j->fd, j->code, 0);
+ }
+ close(j->fd);
+ free(j);
+ return NULL;
+}
+
+int inject_key_async(int code, int press_ms)
{
const char *dev = NULL;
- int kc = keycode_to_device(code, &dev);
- if (kc < 0) return -1;
- int release_ms = press_ms > 0 ? press_ms : 0;
- if (inject_key_event(dev, code, 1) < 0) return -1;
- if (release_ms > 0) usleep((useconds_t)release_ms * 1000);
- else usleep(80 * 1000);
- if (inject_key_event(dev, code, 0) < 0) return -1;
+ if (keycode_to_device(code, &dev) < 0) return -1;
+ int fd = open(dev, O_WRONLY | O_NONBLOCK);
+ if (fd < 0) return -1;
+ keyjob_t *j = malloc(sizeof *j);
+ if (!j) { close(fd); return -1; }
+ j->fd = fd;
+ j->code = code;
+ j->press_ms = mp_key_clamp_ms(press_ms);
+ pthread_t t;
+ if (pthread_create(&t, NULL, keyjob_run, j) != 0) { close(fd); free(j); return -1; }
+ pthread_detach(t);
return 0;
}
@@ -72,16 +104,19 @@ int api_key_single(int sock, int code, const req_t *r)
if (strcmp(press, "tap") && strcmp(press, "long"))
return send_err(sock, 400, "BAD_REQUEST", "press 非法");
if (press_ms <= 0) {
- if (!strcmp(press, "long")) press_ms = 3000;
+ if (!strcmp(press, "long")) press_ms = MP_KEY_PRESS_MS_MAX;
}
- /* 物理键红线:禁止映射 返回(158)/主页(172)/确认/exit(APPS.md)。 */
- if (code == 158 || code == 172 || code == 32 || code == 28 || code == 96)
- return send_err(sock, 403, "FORBIDDEN", "该键码(返回/主页/确认)按物理键红线禁止映射");
+ /* M64 P1-3:ms 无上下限时 `{"press":"long","ms":8000}` 直接强制关机(forceoff 从按下起算)。 */
+ press_ms = mp_key_clamp_ms(press_ms);
+ /* 物理键红线:禁止映射 返回(158)/主页(172)/确认/exit(APPS.md);电源(116) 已于 M64
+ * 从 keycode_to_device 移除,这里再显式拦一次,防将来有人把它加回去。 */
+ if (mp_key_forbidden(code))
+ return send_err(sock, 403, "FORBIDDEN", "该键码(电源/返回/主页/确认)禁止注入");
const char *tmpdev = NULL;
int dispatchable = (keycode_to_device(code, &tmpdev) >= 0);
if (!dispatchable)
- return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/电源/播放/上下曲)");
- if (inject_key_release(code, press_ms) < 0)
+ return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/播放/上下曲)");
+ if (inject_key_async(code, press_ms) < 0)
return send_err(sock, 500, "INTERNAL", "按键注入失败(无法打开输入设备)");
sbuf_t s = {0};
sA(&s, "{\"code\":%d,\"press\":\"%s\",\"dispatched\":true}", code, press);
@@ -106,12 +141,13 @@ int api_key_batch(int sock, const req_t *r)
jv_t *m = jv_obj_get(k, "ms");
if (!c || c->type != J_NUM) continue;
int code = (int)c->inum;
- if (code == 158 || code == 172) continue; /* 红线跳过 */
+ if (mp_key_forbidden(code)) continue; /* 红线跳过(电源/返回/主页) */
int press_ms = (m && m->type == J_NUM) ? (int)m->inum : 0;
- if (p && p->type == J_STR && !strcmp(p->str, "long") && press_ms <= 0) press_ms = 3000;
+ if (p && p->type == J_STR && !strcmp(p->str, "long") && press_ms <= 0) press_ms = MP_KEY_PRESS_MS_MAX;
+ press_ms = mp_key_clamp_ms(press_ms); /* M64 P1-3:与单键同一上限 */
const char *tmpdev = NULL;
if (keycode_to_device(code, &tmpdev) < 0) continue;
- if (inject_key_release(code, press_ms) < 0) continue;
+ if (inject_key_async(code, press_ms) < 0) continue;
if (!first) sA(&s, ",");
first = 0;
sA(&s, "%d", code);
diff --git a/patch_v2/meowpanel/src/mp_util.c b/patch_v2/meowpanel/src/mp_util.c
index 7533307..d509b98 100644
--- a/patch_v2/meowpanel/src/mp_util.c
+++ b/patch_v2/meowpanel/src/mp_util.c
@@ -360,6 +360,46 @@ int run_cmd(const char *cmd, char *out, int outcap)
return code;
}
+/* 执行子进程(execv 传 argv,不经过 shell)并收 stdout+stderr;返回退出码(-1=起不来)。
+ * M64 P1-2:外部可控串(mpk 路径、包名)只走这条——修复前是拼 `PATH=... '' install
+ * ''` 再 popen,路径里一个 `'` 就是 root 命令执行。语义对齐 run_cmd(输出有界截断)。 */
+int run_argv(char *out, int outcap, const char *const argv[])
+{
+ if (outcap <= 0 || !out || !argv || !argv[0]) return -1;
+ out[0] = 0;
+ int pfd[2];
+ if (pipe(pfd) < 0) return -1;
+ pid_t pid = fork();
+ if (pid < 0) { close(pfd[0]); close(pfd[1]); return -1; }
+ if (pid == 0) {
+ dup2(pfd[1], 1);
+ dup2(pfd[1], 2);
+ close(pfd[0]); close(pfd[1]);
+ /* nyai 内部还要找 busybox/tar:PATH 在子进程里设,仍然不经过 shell。 */
+ setenv("PATH", "/bin:/sbin:/usr/bin:/usr/sbin", 1);
+ execv(argv[0], (char *const *)argv);
+ _exit(127);
+ }
+ close(pfd[1]);
+ size_t off = 0;
+ char discard[256];
+ for (;;) {
+ char *dst;
+ size_t room;
+ if (off + 1 < (size_t)outcap) { dst = out + off; room = (size_t)outcap - 1 - off; }
+ else { dst = discard; room = sizeof discard; }
+ ssize_t rd = read(pfd[0], dst, room);
+ if (rd <= 0) break;
+ if (dst != discard) off += (size_t)rd;
+ }
+ close(pfd[0]);
+ out[off] = 0;
+ int st = 0;
+ if (waitpid(pid, &st, 0) < 0) return -1;
+ if (WIFEXITED(st)) return WEXITSTATUS(st);
+ return -1;
+}
+
int64_t now_mono_ms(void)
{
struct timespec ts;
diff --git a/patch_v2/meowpanel/test_mp_argsafe.c b/patch_v2/meowpanel/test_mp_argsafe.c
new file mode 100644
index 0000000..b0ee6fe
--- /dev/null
+++ b/patch_v2/meowpanel/test_mp_argsafe.c
@@ -0,0 +1,115 @@
+/* test_mp_argsafe.c — M64 P1-2 / P1-3 宿主回归:App 安装路径与包名、按键时长与键码红线。
+ *
+ * 纯宿主测试:不连设备、不启面板服务、不发 /api/key/ 请求。
+ *
+ * 编译运行(在 patch_v2/meowpanel 下):
+ * cc -O1 -Wall -Wextra -I src test_mp_argsafe.c -o ~/tmp/test_mp_argsafe
+ * ~/tmp/test_mp_argsafe
+ *
+ * 反向控制:同一套用例分别喂给
+ * ① 「修复前实现」复刻(legacy_pkg_accepts = mp_apps.c:228 旧判定;legacy_path_build =
+ * mp_apps.c:168 旧拼后再 popen)→ 必须放行/注入成功;
+ * ② 修复后实现(mp_pkg_ok/mp_path_unsafe/mp_key_clamp_ms/mp_key_forbidden)→ 必须拒绝。
+ */
+#include
+#include
+#include
+#include
+
+#include "mp_argsafe.h"
+
+static int g_fail;
+
+#define CHECK(cond, ...) do { \
+ if (!(cond)) { printf(" FAIL: "); printf(__VA_ARGS__); printf("\n"); g_fail = 1; } \
+ else { printf(" PASS: "); printf(__VA_ARGS__); printf("\n"); } \
+ } while (0)
+
+/* 修复前 mp_apps.c:118/208/228 的包名判定:只拒 `/` 与 `:`。 */
+static int legacy_pkg_accepts(const char *pkg)
+{
+ return pkg && !strchr(pkg, '/') && !strchr(pkg, ':');
+}
+
+/* 修复前 mp_apps.c:168 的拼法 + mp_util.c:run_cmd 的 popen。 */
+static void legacy_path_build(const char *mpk, char *out, size_t cap)
+{
+ snprintf(out, cap, "PATH=/bin:/sbin:/usr/bin:/usr/sbin '%s' install '%s' 2>&1",
+ "/usr/bin/nyai", mpk);
+}
+
+static int run_shell(const char *cmd, char *out, int cap)
+{
+ out[0] = 0;
+ FILE *f = popen(cmd, "r");
+ if (!f) return -1;
+ size_t off = fread(out, 1, (size_t)cap - 1, f);
+ out[off] = 0;
+ return pclose(f);
+}
+
+int main(void)
+{
+ printf("== M64 P1-2 反向控制:包名 ==================================================\n");
+ /* 审计给出的真实恶意串。 */
+ static const char *bad_pkgs[] = { "x';id;'", "MeowAPPs/..", "..", ".", "..foo", "foo.",
+ "OS.MEOW.READER", "os.meow.reader/../x", "a b", "" };
+ for (unsigned i = 0; i < sizeof bad_pkgs / sizeof bad_pkgs[0]; i++) {
+ const char *p = bad_pkgs[i];
+ int old_acc = legacy_pkg_accepts(p);
+ int new_acc = mp_pkg_ok(p);
+ CHECK(!new_acc, "拒绝包名 `%s`(修复前:%s)", p, old_acc ? "放行 → 可注入" : "也拒");
+ }
+ /* 反向控制的正向对照:旧判定确实放行了这两条。 */
+ CHECK(legacy_pkg_accepts("x';id;'") == 1, "反向控制:修复前放行 `x';id;'`(DELETE /api/apps/x';id;')");
+ /* P2-7 的穿越面是裸 `..`(拼接后 = MEOWAPPS_DIR/.. );`MeowAPPs/..` 含 `/` 反而被旧判定挡住。 */
+ CHECK(legacy_pkg_accepts("..") == 1, "反向控制:修复前放行裸 `..`(MEOWAPPS_DIR/.. 目录穿越)");
+
+ static const char *good_pkgs[] = { "os.meow.reader", "os.meow.app-1", "a_b-c.d", "reader" };
+ for (unsigned i = 0; i < sizeof good_pkgs / sizeof good_pkgs[0]; i++)
+ CHECK(mp_pkg_ok(good_pkgs[i]) == 1, "接受合法包名 `%s`", good_pkgs[i]);
+
+ printf("== M64 P1-2 反向控制:安装路径 ==============================================\n");
+ {
+ /* 审计原串(含 `>` 重定向,写文件的那半句在宿主上不执行——只做字符串判定)。 */
+ const char *audit = "/tmp/a';id>/tmp/pwn;'.mpk";
+ CHECK(mp_path_unsafe(audit) == 1, "拒绝审计原路径 `%s`", audit);
+ CHECK(mp_path_unsafe("/usr/data/mnt/sd_0/MeowOS/tmp/upload.mpk") == 0,
+ "接受面板自己生成的上传临时路径");
+ CHECK(mp_path_unsafe("/sdcard/MeowApps/reader-v1.mpk") == 0, "接受普通路径");
+
+ /* 执行级证明:结构同审计串、但注入体换成 `id`(不写任何文件)。
+ * ① 修复前:值进 shell → `id` 执行。 */
+ const char *pwn = "/tmp/a';id;'.mpk";
+ char cmd[512], out[4096];
+ legacy_path_build(pwn, cmd, sizeof cmd);
+ printf(" 修复前命令:%s\n", cmd);
+ run_shell(cmd, out, sizeof out);
+ CHECK(strstr(out, "uid=") != NULL, "修复前被注入:路径里的 `id` 执行(stdout 含 uid=)");
+ /* ② 修复后:同一串被 mp_path_unsafe 拒掉,且永远不会到达 shell。 */
+ CHECK(mp_path_unsafe(pwn) == 1, "修复后同一串在调用 execv 之前就被拒");
+ }
+
+ printf("== M64 P1-3:press_ms clamp ================================================\n");
+ CHECK(mp_key_clamp_ms(-5) == 0, "-5 → 0");
+ CHECK(mp_key_clamp_ms(0) == 0, "0 → 0(注入侧按 80ms tap 节奏处理)");
+ CHECK(mp_key_clamp_ms(80) == 80, "80 → 80");
+ CHECK(mp_key_clamp_ms(1000) == 1000, "1000 → 1000(上限)");
+ CHECK(mp_key_clamp_ms(3000) == 1000, "3000 → 1000(前端旧 `long` 默认值被收口)");
+ CHECK(mp_key_clamp_ms(8000) == 1000, "8000 → 1000(设备端 forceoff_ms 缺省值:一条请求即强制关机)");
+ CHECK(mp_key_clamp_ms(2147483647) == 1000, "INT_MAX → 1000");
+
+ printf("== M64 P1-3:键码红线 ======================================================\n");
+ /* 修复前 mp_key.c:78 只拦 158/172/32/28/96。 */
+ static const int codes[] = { 116, 158, 172, 32, 28, 96 };
+ for (unsigned i = 0; i < sizeof codes / sizeof codes[0]; i++)
+ CHECK(mp_key_forbidden(codes[i]) == 1, "禁止键码 %d", codes[i]);
+ CHECK(116 == 116 && !(116 == 158 || 116 == 172 || 116 == 32 || 116 == 28 || 116 == 96),
+ "反向控制:修复前的红线**放行** 116(KEY_POWER)");
+ static const int allowed[] = { 114, 115, 163, 164, 165 };
+ for (unsigned i = 0; i < sizeof allowed / sizeof allowed[0]; i++)
+ CHECK(mp_key_forbidden(allowed[i]) == 0, "允许键码 %d(音量/上下曲/播放)", allowed[i]);
+
+ printf(g_fail ? "\nRESULT: FAIL\n" : "\nRESULT: ALL PASS\n");
+ return g_fail ? 1 : 0;
+}
diff --git a/patch_v2/meowpanel/web/mp_pages.js b/patch_v2/meowpanel/web/mp_pages.js
index 77af2f0..d9ae8da 100644
--- a/patch_v2/meowpanel/web/mp_pages.js
+++ b/patch_v2/meowpanel/web/mp_pages.js
@@ -2293,7 +2293,6 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
h += remoteBtn('play', 'play', '播放/暂停', '媒体播放/暂停切换(code=164)');
h += remoteBtn('next', 'next', '下一曲', '下一曲(code=163)');
h += remoteBtn('prev', 'prev', '上一曲', '上一曲(code=165)');
- h += remoteBtn('power', 'power', '电源', '电源键(单击;code=116)');
h += remoteBtn('back', 'back', '返回', '左缘右滑返回(注入设备已识别的返回手势)');
h += remoteBtn('home', 'home', '回桌面', '底部上滑回桌面(注入设备已识别的回桌面手势)');
h += '';
@@ -2302,9 +2301,9 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
'' +
'' + ctlBright.value + '' +
'';
- h += '';
h += '滑动手势(在遥控板上):上滑=音量+ · 下滑=音量- · 左滑=下一曲 · 右滑=上一曲 · 左缘右滑=返回。
' +
- '返回/回桌面走手势注入(/api/input/gesture,模拟设备已识别的左缘右滑/底部上滑),不走被物理键红线禁注入的 /api/key 158/172。
';
+ '返回/回桌面走手势注入(/api/input/gesture,模拟设备已识别的左缘右滑/底部上滑),不走被物理键红线禁注入的 /api/key 158/172。
' +
+ '电源/关机不走按键注入(/api/key/116 已下线:设备端强制关机计时从按下起算,一条请求即关机)——请到「系统」页用关机(带二次确认)。
';
h += '';
return h;
}
@@ -2769,12 +2768,10 @@ const { $, $$, LOG_GROUP_NAME, SPARK_MAX, api, apiDownload, applyTheme, authMode
else if (a === 'play') sendKey(164, 'tap');
else if (a === 'next') sendKey(163, 'tap');
else if (a === 'prev') sendKey(165, 'tap');
- else if (a === 'power') sendKey(116, 'tap');
else if (a === 'back') sendGesture('back');
else if (a === 'home') sendGesture('home');
return;
}
- if (e.target.closest('#ctl-powerlong')) { sendKey(116, 'long', 3000); return; }
if (e.target.closest('#screen-img') || e.target.closest('#screen-canvas')) { screenTap(e); return; }
});
// 滑动手势(仅遥控板);屏幕区点击=注入,不参与手势
From 008e177307ae65eb4fbedb90d6de4692cac6a561 Mon Sep 17 00:00:00 2001
From: AskaEth
Date: Thu, 12 Nov 2026 08:39:32 +0800
Subject: [PATCH 2/2] =?UTF-8?q?fix(panel):=20M64=20r1=20P2-1=20=E8=BF=9E?=
=?UTF-8?q?=E7=BB=AD/=E6=89=B9=E9=87=8F=E6=8C=89=E9=94=AE=E8=A2=AB?=
=?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=B3=A8=E5=85=A5=E5=90=88=E5=B9=B6=20?=
=?UTF-8?q?=E2=80=94=20=E6=94=B9=E5=8D=95=20worker=20=E4=B8=B2=E8=A1=8C?=
=?UTF-8?q?=E9=98=9F=E5=88=97?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/api/key 改异步后每个请求起一个 detached 线程,N 次 press 全部落在同一段里;launcher 用
EVIOCGKEY 10ms 状态采样读键,重叠 press 只产生 1 个 DOWN 边沿 → 滑动调音量 5 步变 1 步、
batch 顺序语义失效。
- 新增 src/mp_keyq.{c,h}:常驻 worker + FIFO 队列。请求线程只 open + 入队后立即返回
(8080 仍不被阻塞);worker 按提交顺序逐个「按下→等待→抬起」,两次按键之间留
MP_KEYQ_GAP_MS(20ms)=2× 采样周期,保证每次按下都被采样到。
- 批次 = 一个作业:批次内顺序、批次间顺序都保证;payload 上限 MP_KEYQ_JOB_MAX=32。
- 有界等待:队列总时长预算 2000ms(含执行中作业),超预算/队列满立即回 503 BUSY,
无界等待(单核即挂死)被排除。
- mp_key.c:inject_key_seq() 统一 open+clamp+入队(-1 设备 / -2 队列忙),
api_key_single / api_key_batch 都走它;设备打不开仍同步报 500(原语义不变)。
- 测试 test_mp_keyq.c(宿主,不碰 /dev/input、不发 /api/key)反向控制:修复前的并发注入
5 连击被 10ms 采样器数成 1 次按下;修复后同一 5 连击数成 5 次且顺序一致;批次 3 个同键
tap + 紧随一个单键 = 4 次按下不丢步;超预算立即被拒、释放后可继续提交。
安全三条(P1-1/P1-2/P1-3)本轮一行未动。
---
patch_v2/meowpanel/README.md | 10 +-
patch_v2/meowpanel/src/mp.h | 3 +-
patch_v2/meowpanel/src/mp_key.c | 97 ++++++++++------
patch_v2/meowpanel/src/mp_keyq.c | 102 ++++++++++++++++
patch_v2/meowpanel/src/mp_keyq.h | 50 ++++++++
patch_v2/meowpanel/test_mp_keyq.c | 187 ++++++++++++++++++++++++++++++
6 files changed, 409 insertions(+), 40 deletions(-)
create mode 100644 patch_v2/meowpanel/src/mp_keyq.c
create mode 100644 patch_v2/meowpanel/src/mp_keyq.h
create mode 100644 patch_v2/meowpanel/test_mp_keyq.c
diff --git a/patch_v2/meowpanel/README.md b/patch_v2/meowpanel/README.md
index f3b2a67..ff94c87 100644
--- a/patch_v2/meowpanel/README.md
+++ b/patch_v2/meowpanel/README.md
@@ -143,7 +143,7 @@ http://.local:8080/
| GET | `/api/apps/:pkg/export` | `nyai export` 下载流 | ✅ |
| DELETE | `/api/apps/:pkg` | `nyai remove [--purge]` | ✅ |
| POST | `/api/key/:code` | 按键注入(tap/long;`ms` clamp 0..1000;电源 116 已下线) | ✅(物理键红线内) |
-| POST | `/api/key` | 批量/顺序注入 | ✅ |
+| POST | `/api/key` | 批量/顺序注入(一个批次=一个串行作业) | ✅ |
| GET | `/api/logs` | `MeowOS/logs` 文件列表(每文件含 `group` 来源分组) | ✅(无卡→503) |
| GET | `/api/logs/crash` | **崩溃汇总**:解析最新 `crash_.log`(meow_crash 落盘格式)→ 每实体崩溃数 + 最近崩溃时间/信号 + dmesg 计数 | ✅(无卡→503;无 crash 日志→空汇总) |
| GET | `/api/logs/read?file=&lines=` | 读日志(上限 1000 行;`..`/绝对→403;敏感名→403) | ✅ |
@@ -238,8 +238,12 @@ http://.local:8080/
(一条 `{"press":"long","ms":8000}` 即强制关机)。电源/关机只走 `/api/system/shutdown`(前端有确认弹窗)。
- `tap`(默认,抬起间隔 80ms)、`long`(按住 ms,缺省 1000);`hold` → 501。
**`ms` 全局 clamp 到 0..1000**(`mp_key_clamp_ms`),单键与批量同一上限。
-- 注入在**独立线程**里做按下→等待→抬起(`inject_key_async`):修复前是在请求线程 `usleep(ms)`,
- 一个键请求就把单线程 8080 服务整段阻塞。设备 fd 仍由请求线程同步 open,打不开 → 500。
+- 注入在**串行队列**里执行(`mp_keyq.c` 单 worker + FIFO):请求线程只 open 设备 + 入队后立即返回,
+ 一个键请求不会阻塞单线程 8080 服务;worker 按提交顺序逐个「按下→等待→抬起」,两次按键之间留
+ `MP_KEYQ_GAP_MS`(20ms) 间隔——launcher 读键是 10ms 的 EVIOCGKEY 状态采样,release 后立刻 press
+ 会被采样合并(连续 tap 退化成 1 次)。批次 = 一个作业,批次内顺序与批次间顺序都保证。
+ 队列总时长预算 2000ms:超预算/队列满立即 **503 `BUSY`**(不做无界等待,单核无界等=挂死)。
+ 设备 fd 仍由请求线程同步 open,打不开 → 500。
- 局限:若 `/dev/input/event0/event2` 在设备上不可写(权限/路径差异),注入失败 → 500;注入为**即时性注入**,
不在 launcher 内维护长按状态机(与实体按键的 `longpressed` 语义近似,见 README 已知边界)。
diff --git a/patch_v2/meowpanel/src/mp.h b/patch_v2/meowpanel/src/mp.h
index ddbdd9a..b28e308 100644
--- a/patch_v2/meowpanel/src/mp.h
+++ b/patch_v2/meowpanel/src/mp.h
@@ -323,7 +323,8 @@ int files_list(int sock, const char *relpath);
char *read_text_file_limited(const char *abs, long long *size, int *truncated);
int keycode_to_device(int code, const char **dev);
int inject_key_event(const char *dev, int code, int down);
-int inject_key_async(int code, int press_ms); /* M64 P1-3:释放放独立线程,不阻塞 panel */
+int inject_key_async(int code, int press_ms); /* 入串行队列后立即返回(0/-1 设备/-2 队列忙) */
+int inject_key_seq(const int *codes, const int *press_ms, int n); /* 同上,一批按键一个作业 */
int read_small_file(const char *path, char *buf, int cap);
int apps_scan_dir(const char *appdir, const char *name, sbuf_t *out, int want_detail);
void req_free(req_t *r);
diff --git a/patch_v2/meowpanel/src/mp_key.c b/patch_v2/meowpanel/src/mp_key.c
index f6bbb0a..491b55b 100644
--- a/patch_v2/meowpanel/src/mp_key.c
+++ b/patch_v2/meowpanel/src/mp_key.c
@@ -1,7 +1,7 @@
/* mp_key.c — 远程控制域:按键注入(写 /dev/input/event* EV_KEY,物理键红线内)。 */
#include "mp.h"
#include "mp_argsafe.h" /* M64 P1-3:press_ms 上限 */
-#include /* 按键释放放独立线程:不再用 usleep 阻塞单线程 panel */
+#include "mp_keyq.h" /* M64 r1 P2-1:串行注入队列(不阻塞 8080,也不合并连续按键) */
#define PWR_KEYDEV "/dev/input/event0"
#define ADC_KEYDEV "/dev/input/event2"
@@ -49,39 +49,47 @@ int inject_key_event(const char *dev, int code, int down)
return ok;
}
-typedef struct { int fd; int code; int press_ms; } keyjob_t;
-
-/* 按下→等→松开在独立线程里做。修复前是请求线程里 usleep(press_ms)(还无上限):
- * 一个键请求就把单线程 8080 服务整段阻塞住。press_ms 已 clamp 到 ≤1000ms,
- * 线程是 drop 型(detach,自带 job 内存与 fd),请求线程立即返回。
- * 设备 fd 仍由请求线程同步 open(失败立刻如实报 500,保住原有错误语义)。 */
-static void *keyjob_run(void *arg)
+/* 队列 worker 调用的注入体:按下→等→抬起,一个键完整走完才轮到下一个。
+ * fd 由本函数负责 close(mp_keyq.h 契约)。 */
+static void ev_sink(int fd, int code, int press_ms)
{
- keyjob_t *j = arg;
- if (write_key_fd(j->fd, j->code, 1) == 0) {
- usleep((useconds_t)(j->press_ms > 0 ? j->press_ms : 80) * 1000);
- write_key_fd(j->fd, j->code, 0);
+ if (write_key_fd(fd, code, 1) == 0) {
+ usleep((useconds_t)(press_ms > 0 ? press_ms : 80) * 1000);
+ write_key_fd(fd, code, 0);
}
- close(j->fd);
- free(j);
- return NULL;
+ close(fd);
+}
+
+/* 把一个按键序列作为**一个作业**提交到串行队列:
+ * - 作业内按键顺序执行,作业之间按提交顺序执行(P2-1:连续 tap 不再合并);
+ * - 请求线程只 open + 入队,不等待注入完成(P1-3:8080 不被阻塞)。
+ * 设备 fd 仍在请求线程同步 open(失败立刻如实报 500,保住原有错误语义)。
+ * 返回 0=已入队;-1=设备打不开/键码不支持;-2=队列忙(调用方回 503,别重试到无界等待)。 */
+int inject_key_seq(const int *codes, const int *press_ms, int n)
+{
+ int fds[MP_KEYQ_JOB_MAX], ms[MP_KEYQ_JOB_MAX];
+ int opened = 0, rc = -1;
+ if (!codes || !press_ms || n <= 0 || n > MP_KEYQ_JOB_MAX) return -1;
+ for (int i = 0; i < n; i++) {
+ const char *dev = NULL;
+ if (keycode_to_device(codes[i], &dev) < 0) goto fail;
+ int fd = open(dev, O_WRONLY | O_NONBLOCK);
+ if (fd < 0) goto fail;
+ fds[opened] = fd;
+ ms[opened] = mp_key_clamp_ms(press_ms[i]);
+ opened++;
+ }
+ rc = mp_keyq_submit(fds, codes, ms, opened, ev_sink);
+ if (rc != MP_KEYQ_OK) goto fail;
+ return 0;
+fail:
+ for (int i = 0; i < opened; i++) close(fds[i]);
+ return rc == MP_KEYQ_ERR_BUSY ? -2 : -1;
}
int inject_key_async(int code, int press_ms)
{
- const char *dev = NULL;
- if (keycode_to_device(code, &dev) < 0) return -1;
- int fd = open(dev, O_WRONLY | O_NONBLOCK);
- if (fd < 0) return -1;
- keyjob_t *j = malloc(sizeof *j);
- if (!j) { close(fd); return -1; }
- j->fd = fd;
- j->code = code;
- j->press_ms = mp_key_clamp_ms(press_ms);
- pthread_t t;
- if (pthread_create(&t, NULL, keyjob_run, j) != 0) { close(fd); free(j); return -1; }
- pthread_detach(t);
- return 0;
+ return inject_key_seq(&code, &press_ms, 1);
}
int api_key_single(int sock, int code, const req_t *r)
@@ -116,7 +124,10 @@ int api_key_single(int sock, int code, const req_t *r)
int dispatchable = (keycode_to_device(code, &tmpdev) >= 0);
if (!dispatchable)
return send_err(sock, 422, "UNSUPPORTED", "不支持的键码(仅音量/播放/上下曲)");
- if (inject_key_async(code, press_ms) < 0)
+ int irc = inject_key_async(code, press_ms);
+ if (irc == -2)
+ return send_err(sock, 503, "BUSY", "按键队列忙(连续按键过快或按键总时长超预算),请稍后重试");
+ if (irc < 0)
return send_err(sock, 500, "INTERNAL", "按键注入失败(无法打开输入设备)");
sbuf_t s = {0};
sA(&s, "{\"code\":%d,\"press\":\"%s\",\"dispatched\":true}", code, press);
@@ -132,8 +143,9 @@ int api_key_batch(int sock, const req_t *r)
if (!root) return send_err(sock, 400, "BAD_REQUEST", "body 非合法 JSON");
jv_t *keys = jv_obj_get(root, "keys");
if (!keys || keys->type != J_ARR) { jv_free(root); return send_err(sock, 400, "BAD_REQUEST", "缺 keys 数组"); }
- sbuf_t s = {0};
- int first = 1;
+ /* P2-1:整个批次作为**一个作业**提交 → 批次内顺序与提交顺序都保证(修复前每个键起一个
+ * 线程并发注入,launcher 的 EVIOCGKEY 采样会把重叠 press 合并成一次)。 */
+ int codes[MP_KEYQ_JOB_MAX], ms[MP_KEYQ_JOB_MAX], n = 0, overflow = 0;
for (const jv_t *k = keys->child; k; k = k->next) {
if (k->type != J_OBJ) continue;
jv_t *c = jv_obj_get(k, "code");
@@ -147,14 +159,27 @@ int api_key_batch(int sock, const req_t *r)
press_ms = mp_key_clamp_ms(press_ms); /* M64 P1-3:与单键同一上限 */
const char *tmpdev = NULL;
if (keycode_to_device(code, &tmpdev) < 0) continue;
- if (inject_key_async(code, press_ms) < 0) continue;
- if (!first) sA(&s, ",");
- first = 0;
- sA(&s, "%d", code);
+ if (n >= MP_KEYQ_JOB_MAX) { overflow = 1; break; }
+ codes[n] = code;
+ ms[n] = press_ms;
+ n++;
}
jv_free(root);
+ if (overflow) return send_err(sock, 400, "BAD_REQUEST", "单次批量按键最多 32 个");
+ sbuf_t s = {0};
+ if (n > 0) {
+ int irc = inject_key_seq(codes, ms, n);
+ if (irc == -2)
+ return send_err(sock, 503, "BUSY", "按键队列忙(连续按键过快或按键总时长超预算),请稍后重试");
+ if (irc < 0)
+ return send_err(sock, 500, "INTERNAL", "按键注入失败(无法打开输入设备)");
+ for (int i = 0; i < n; i++) {
+ if (i) sA(&s, ",");
+ sA(&s, "%d", codes[i]);
+ }
+ }
sbuf_t o = {0};
- if (first) sA(&o, "{\"dispatched\":[]}");
+ if (!s.b) sA(&o, "{\"dispatched\":[]}");
else sA(&o, "{\"dispatched\":[%s]}", s.b);
int ret = send_json(sock, 200, o.b ? o.b : "");
sbuf_free(&s); sbuf_free(&o);
diff --git a/patch_v2/meowpanel/src/mp_keyq.c b/patch_v2/meowpanel/src/mp_keyq.c
new file mode 100644
index 0000000..c60b0cc
--- /dev/null
+++ b/patch_v2/meowpanel/src/mp_keyq.c
@@ -0,0 +1,102 @@
+/* mp_keyq.c — 按键注入串行队列(单 worker + FIFO,见 mp_keyq.h 背景)。
+ * 请求线程只入队;worker 串行执行,保证 launcher 的 EVIOCGKEY 状态采样能看到每一次
+ * press/release 边沿(连续 tap 不再被合并成一次)。 */
+#include "mp_keyq.h"
+
+#include
+#include
+#include
+
+typedef struct {
+ int n;
+ int dur; /* 本作业总时长(ms),用于队列预算 */
+ mp_keyq_sink_t sink;
+ int fd[MP_KEYQ_JOB_MAX];
+ int code[MP_KEYQ_JOB_MAX];
+ int ms[MP_KEYQ_JOB_MAX];
+} keyjob_t;
+
+static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
+static pthread_cond_t g_cv = PTHREAD_COND_INITIALIZER;
+static keyjob_t g_q[MP_KEYQ_LEN];
+static int g_head, g_tail, g_count;
+static int g_busy_ms; /* 队列中(含执行中)作业的时长合计 */
+static int g_worker_up;
+static int g_done;
+
+/* 与 mp_key.c 的 tap 节奏一致:ms<=0 按 80ms 计;每个键后面还有 MP_KEYQ_GAP_MS 的间隔,
+ * 让 launcher 的 10ms 状态采样能看到「抬起→再按下」的边沿(否则连续 tap 仍被合并)。 */
+static int job_dur(const int *ms, int n)
+{
+ int d = 0;
+ for (int i = 0; i < n; i++) d += (ms[i] > 0 ? ms[i] : 80) + MP_KEYQ_GAP_MS;
+ return d;
+}
+
+static void *keyq_worker(void *arg)
+{
+ (void)arg;
+ for (;;) {
+ pthread_mutex_lock(&g_mu);
+ while (!g_count) pthread_cond_wait(&g_cv, &g_mu);
+ keyjob_t j = g_q[g_head];
+ g_head = (g_head + 1) % MP_KEYQ_LEN;
+ g_count--;
+ pthread_mutex_unlock(&g_mu);
+
+ for (int i = 0; i < j.n; i++) {
+ j.sink(j.fd[i], j.code[i], j.ms[i]);
+ usleep(MP_KEYQ_GAP_MS * 1000); /* 抬起到下一次按下之间留出采样窗口 */
+ }
+
+ pthread_mutex_lock(&g_mu);
+ g_busy_ms -= j.dur;
+ g_done++;
+ pthread_mutex_unlock(&g_mu);
+ }
+ return NULL;
+}
+
+int mp_keyq_submit(const int *fds, const int *codes, const int *press_ms, int n,
+ mp_keyq_sink_t sink)
+{
+ if (!fds || !codes || !press_ms || !sink || n <= 0 || n > MP_KEYQ_JOB_MAX)
+ return MP_KEYQ_ERR_BUSY;
+ int dur = job_dur(press_ms, n);
+
+ pthread_mutex_lock(&g_mu);
+ if (!g_worker_up) {
+ pthread_t t;
+ if (pthread_create(&t, NULL, keyq_worker, NULL) != 0) {
+ pthread_mutex_unlock(&g_mu);
+ return MP_KEYQ_ERR_BUSY;
+ }
+ pthread_detach(t);
+ g_worker_up = 1;
+ }
+ if (g_count >= MP_KEYQ_LEN || g_busy_ms + dur > MP_KEYQ_BUDGET_MS) {
+ pthread_mutex_unlock(&g_mu);
+ return MP_KEYQ_ERR_BUSY;
+ }
+ keyjob_t *j = &g_q[g_tail];
+ j->n = n;
+ j->dur = dur;
+ j->sink = sink;
+ memcpy(j->fd, fds, sizeof j->fd[0] * (size_t)n);
+ memcpy(j->code, codes, sizeof j->code[0] * (size_t)n);
+ memcpy(j->ms, press_ms, sizeof j->ms[0] * (size_t)n);
+ g_tail = (g_tail + 1) % MP_KEYQ_LEN;
+ g_count++;
+ g_busy_ms += dur;
+ pthread_cond_signal(&g_cv);
+ pthread_mutex_unlock(&g_mu);
+ return MP_KEYQ_OK;
+}
+
+int mp_keyq_done_count(void)
+{
+ pthread_mutex_lock(&g_mu);
+ int d = g_done;
+ pthread_mutex_unlock(&g_mu);
+ return d;
+}
diff --git a/patch_v2/meowpanel/src/mp_keyq.h b/patch_v2/meowpanel/src/mp_keyq.h
new file mode 100644
index 0000000..ffca52c
--- /dev/null
+++ b/patch_v2/meowpanel/src/mp_keyq.h
@@ -0,0 +1,50 @@
+/* mp_keyq.h — 按键注入串行队列(M64 r1 P2-1)。
+ *
+ * 背景:M64 P1-3 把「按下→等待→抬起」移出请求线程(避免阻塞单线程 8080),最初的实现是
+ * 每个请求起一个 detached 线程 —— 于是**连续 tap 会并发重叠**:launcher 侧读键是
+ * EVIOCGKEY 状态采样(keys.c kp_poll / main_menu.c 每轮采样一次),N 个重叠的 press 只
+ * 产生 1 个 DOWN 边沿 → 滑动调音量 5 步退化成 1 步,batch 的「顺序」语义也失效。
+ *
+ * 本模块用一个常驻 worker + FIFO 队列恢复串行语义:
+ * - 请求线程只做 open() + 入队(**从不阻塞、从不等待注入完成**),随后立即返回;
+ * - 一个作业内的按键按数组顺序串行执行,作业之间也按提交顺序串行;
+ * - 队列有总时长预算(MP_KEYQ_BUDGET_MS):超预算或队列满立即返回 MP_KEYQ_ERR_BUSY,
+ * 调用方回 503 明确错误,绝不做无界等待(单核设备上无界等就是挂死)。
+ *
+ * fd 生命周期:入队成功后由 sink 负责(生产 sink 在执行完 press/release 后 close);
+ * 入队失败时 fd 仍属调用方,由调用方 close。本模块自己不碰 fd 的关闭。
+ */
+#ifndef MP_KEYQ_H
+#define MP_KEYQ_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define MP_KEYQ_JOB_MAX 32 /* 单个作业(一次批量请求)最多按键数 */
+#define MP_KEYQ_LEN 32 /* 队列最多作业数(内存上限;通常先撞时间预算) */
+#define MP_KEYQ_BUDGET_MS 2000 /* 队列总时长预算(含正在执行的作业) */
+/* 两次按键之间的强制间隔。launcher 用 EVIOCGKEY 状态轮询读键(APPS.md:10ms 采样),
+ * 只有采样到「抬起」才会把下一次 press 认成新的按下;release 后立刻 press 会被采样合并
+ * ——这正是 P2-1 的 5 步变 1 步。20ms = 2× 采样周期,保证每次按下都被看见。 */
+#define MP_KEYQ_GAP_MS 20
+
+#define MP_KEYQ_OK 0
+#define MP_KEYQ_ERR_BUSY (-2)
+
+/* 注入回调:串行调用,永不同时进入。实现方负责 close(fd)。 */
+typedef void (*mp_keyq_sink_t)(int fd, int code, int press_ms);
+
+/* 提交一组按键(n = 1..MP_KEYQ_JOB_MAX)。fds/codes/press_ms 在返回前复制到队列内部。
+ * 返回 MP_KEYQ_OK 或 MP_KEYQ_ERR_BUSY(队列满/超预算)。 */
+int mp_keyq_submit(const int *fds, const int *codes, const int *press_ms, int n,
+ mp_keyq_sink_t sink);
+
+/* 已完成作业数(测试/诊断用)。 */
+int mp_keyq_done_count(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MP_KEYQ_H */
diff --git a/patch_v2/meowpanel/test_mp_keyq.c b/patch_v2/meowpanel/test_mp_keyq.c
new file mode 100644
index 0000000..c2fcd2d
--- /dev/null
+++ b/patch_v2/meowpanel/test_mp_keyq.c
@@ -0,0 +1,187 @@
+/* test_mp_keyq.c — M64 r1 P2-1 宿主回归:连续 tap 不被合并 + 顺序保证 + 有界预算。
+ *
+ * 纯宿主测试:不碰 /dev/input、不发 /api/key、不启面板服务。注入 sink 是记录器,
+ * 另有一个「电平采样器」线程模拟 launcher 的读键方式(EVIOCGKEY 状态采样:只有从 0→1
+ * 的跳变才算一次按键,见 meow_launcherd/keys.c kp_poll + main_menu.c 每轮采样)。
+ *
+ * 编译运行(在 patch_v2/meowpanel 下):
+ * cc -O1 -Wall -Wextra -I src -pthread test_mp_keyq.c src/mp_keyq.c -o ~/tmp/test_mp_keyq
+ * ~/tmp/test_mp_keyq
+ *
+ * 反向控制:同一个「5 连击」分别用
+ * ① 修复前的实现方式(每个按键一个 detached 线程并发 press/sleep/release)→ 采样器只看到 1 次按下;
+ * ② 修复后的队列(mp_keyq_submit 一个作业 / 5 个单键作业)→ 采样器看到 5 次按下。
+ */
+#include
+#include
+#include
+#include
+
+#include "mp_keyq.h"
+
+static int g_fail;
+
+#define CHECK(cond, ...) do { \
+ if (!(cond)) { printf(" FAIL: "); printf(__VA_ARGS__); printf("\n"); g_fail = 1; } \
+ else { printf(" PASS: "); printf(__VA_ARGS__); printf("\n"); } \
+ } while (0)
+
+/* ---- 被注入的「设备」:单一电平(key down/up) ---- */
+static pthread_mutex_t g_dev_mu = PTHREAD_MUTEX_INITIALIZER;
+static int g_level; /* 0=抬起 1=按下 */
+
+static void dev_set(int v)
+{
+ pthread_mutex_lock(&g_dev_mu);
+ g_level = v;
+ pthread_mutex_unlock(&g_dev_mu);
+}
+static int dev_get(void)
+{
+ pthread_mutex_lock(&g_dev_mu);
+ int v = g_level;
+ pthread_mutex_unlock(&g_dev_mu);
+ return v;
+}
+
+/* ---- 记录器 sink:按调用顺序记录,并驱动设备电平 ---- */
+#define REC_MAX 64
+static int g_rec_code[REC_MAX];
+static int g_rec_n;
+
+static void rec_sink(int fd, int code, int press_ms)
+{
+ (void)fd;
+ dev_set(1);
+ usleep((useconds_t)(press_ms > 0 ? press_ms : 80) * 1000);
+ dev_set(0);
+ /* 记录顺序与时序(在 release 之后记,避免与记录数组本身的并发) */
+ pthread_mutex_lock(&g_dev_mu);
+ if (g_rec_n < REC_MAX) g_rec_code[g_rec_n] = code;
+ g_rec_n++;
+ pthread_mutex_unlock(&g_dev_mu);
+}
+
+/* ---- 电平采样器:2ms 一次,只数 0→1 的跳变(模拟 launcher kp_poll) ---- */
+static volatile int g_samp_stop, g_samp_edges;
+
+static void *sampler(void *arg)
+{
+ (void)arg;
+ int prev = 0;
+ while (!g_samp_stop) {
+ int cur = dev_get();
+ if (cur && !prev) g_samp_edges++;
+ prev = cur;
+ usleep(2 * 1000);
+ }
+ return NULL;
+}
+
+static void rec_reset(void)
+{
+ pthread_mutex_lock(&g_dev_mu);
+ g_rec_n = 0;
+ memset(g_rec_code, 0, sizeof g_rec_code);
+ pthread_mutex_unlock(&g_dev_mu);
+ g_samp_edges = 0;
+ dev_set(0);
+}
+
+/* 等队列把 n 个作业执行完(有界等待,测试用)。 */
+static int wait_done(int base, int n, int timeout_ms)
+{
+ for (int w = 0; w < timeout_ms; w += 5) {
+ if (mp_keyq_done_count() - base >= n) return 1;
+ usleep(5 * 1000);
+ }
+ return 0;
+}
+
+/* 修复前的做法:每个按键一个 detached 线程,press→sleep→release 并发跑。 */
+static void *legacy_thread(void *arg)
+{
+ int ms = (int)(long)arg;
+ dev_set(1);
+ usleep((useconds_t)ms * 1000);
+ dev_set(0);
+ return NULL;
+}
+
+int main(void)
+{
+ pthread_t st;
+ pthread_create(&st, NULL, sampler, NULL);
+ usleep(10 * 1000); /* 让采样器先跑起来 */
+
+ printf("== 反向控制:修复前的并发注入,5 连击只看到 1 次按下 ==\n");
+ rec_reset();
+ {
+ pthread_t t[5];
+ for (int i = 0; i < 5; i++) pthread_create(&t[i], NULL, legacy_thread, (void *)(long)40);
+ usleep(120 * 1000); /* 5 个线程同时 press、40ms 后各自 release */
+ for (int i = 0; i < 5; i++) pthread_join(t[i], NULL);
+ }
+ usleep(20 * 1000);
+ CHECK(g_samp_edges >= 1 && g_samp_edges < 5,
+ "修复前:5 个重叠 press 被采样成 %d 次按下(< 5,正是滑动只走 1 步的原因)", g_samp_edges);
+
+ printf("== 修复后:5 个单键作业串行 → 5 次按下,顺序一致 ==\n");
+ rec_reset();
+ int base = mp_keyq_done_count();
+ int codes5[5] = { 114, 115, 163, 164, 165 };
+ for (int i = 0; i < 5; i++) {
+ int fd = i;
+ int rc = mp_keyq_submit(&fd, &codes5[i], (int[]){ 40 }, 1, rec_sink);
+ CHECK(rc == MP_KEYQ_OK, "第 %d 个 tap 入队成功(请求线程不等待注入完成)", i + 1);
+ }
+ CHECK(wait_done(base, 5, 2000), "5 个作业在预算内执行完");
+ usleep(20 * 1000);
+ CHECK(g_samp_edges == 5, "采样器看到 5 次按下(修复前只有 1 次)");
+ {
+ int ok = 1;
+ for (int i = 0; i < 5; i++) if (g_rec_code[i] != codes5[i]) ok = 0;
+ CHECK(ok && g_rec_n == 5, "记录顺序 = 提交顺序(114,115,163,164,165)");
+ }
+
+ printf("== 修复后:批次内部顺序 + 与下一个请求不重叠(不丢步) ==\n");
+ rec_reset();
+ base = mp_keyq_done_count();
+ {
+ int fds[3] = { 0, 1, 2 }, codes[3] = { 115, 115, 115 }, ms[3] = { 30, 30, 30 };
+ int rc = mp_keyq_submit(fds, codes, ms, 3, rec_sink);
+ CHECK(rc == MP_KEYQ_OK, "批次(3 个同键 tap)作为一个作业入队");
+ int fd4 = 3, code4 = 114;
+ rc = mp_keyq_submit(&fd4, &code4, (int[]){ 30 }, 1, rec_sink);
+ CHECK(rc == MP_KEYQ_OK, "紧随其后的单个请求入队成功(不丢)");
+ }
+ CHECK(wait_done(base, 2, 2000), "批次 + 单键在预算内执行完");
+ usleep(20 * 1000);
+ CHECK(g_samp_edges == 4, "采样器看到 4 次按下(3 个同键 tap 未被合并)");
+ CHECK(g_rec_code[0] == 115 && g_rec_code[1] == 115 && g_rec_code[2] == 115 && g_rec_code[3] == 114,
+ "批次内 3 次 115 顺序执行,之后才是下一个请求的 114");
+
+ printf("== 有界预算:超预算立即拒绝,不做无界等待 ==\n");
+ {
+ int fd = 9, code = 115;
+ int base2 = mp_keyq_done_count();
+ /* 每个作业实际时长 = press_ms + MP_KEYQ_GAP_MS(20),预算 2000ms */
+ int rc1 = mp_keyq_submit(&fd, &code, (int[]){ 800 }, 1, rec_sink);
+ CHECK(rc1 == MP_KEYQ_OK, "800ms 作业入队(820ms 预算占用)");
+ int rc2 = mp_keyq_submit(&fd, &code, (int[]){ 800 }, 1, rec_sink);
+ CHECK(rc2 == MP_KEYQ_OK, "第二个 800ms 作业入队(累计 1640ms)");
+ int rc3 = mp_keyq_submit(&fd, &code, (int[]){ 400 }, 1, rec_sink);
+ CHECK(rc3 == MP_KEYQ_ERR_BUSY, "第三个作业超 2000ms 预算 → 立刻 MP_KEYQ_ERR_BUSY(503,不排队等)");
+ int rc4 = mp_keyq_submit(&fd, &code, (int[]){ 0 }, MP_KEYQ_JOB_MAX + 1, rec_sink);
+ CHECK(rc4 == MP_KEYQ_ERR_BUSY, "作业超过 MP_KEYQ_JOB_MAX → 拒绝");
+ CHECK(wait_done(base2, 2, 4000), "两个 800ms 作业在预算内执行完");
+ int rc5 = mp_keyq_submit(&fd, &code, (int[]){ 80 }, 1, rec_sink);
+ CHECK(rc5 == MP_KEYQ_OK, "预算释放后可继续提交(拒绝不是永久性的)");
+ }
+
+ g_samp_stop = 1;
+ pthread_join(st, NULL);
+
+ printf(g_fail ? "\nRESULT: FAIL\n" : "\nRESULT: ALL PASS\n");
+ return g_fail ? 1 : 0;
+}