feat(nyai): M35 包身份以已签名 app.json 清单为准 + 暂存迁到 SD 并必清理

包名/版本/依赖不再从 .mpk 文件名推导(用户定案:不对安装包文件名强依赖,
一切信息从清单取),改为解包后读 app.json.package 作唯一权威:
  - 落地目录 MeowAPPs/<pkg>、meow_verify --package <pkg>、防降级基准全用清单包名;
  - 新增 footer.package == app.json.package 一致性检查,不一致拒装并同时报两个名字;
  - valid_pkg 拒绝 "." / ".."(此前 MeowAPPs/.. 可越出安装目录并被 rm_rf);
  - index 的 Package 字段同样取自清单(缺 package 的包跳过并告警);
  - scan/export 同步;--help 与错误文案去掉「需要 <pkg>.mpk」。

暂存:MeowAPPs/.stg_<pid>_<pkg>(/usr/data NAND,仅剩 4MB)
  → <MeowROOT>/MeowOS/tmp/pkgs/<pkg>-<UTC时间戳>-<pid>-<n>/(真机即 SD 卡):
  - mkdir 原子占位 + 失败重试 + pid/序号保证唯一,时间戳只给人看;
  - SD 未挂载/不可写直接拒装(不回退 /usr/data:目标本就在 SD,且跨 fs rename 会 EXDEV);
  - 解包前后各一道防护:tar -tv 只允许普通文件/目录(拒符号链接/硬链接/设备/FIFO),
    解包后复核暂存树(非普通文件、nlink>1 一律拒);
  - 中央化 cleanup + SIGINT/SIGTERM/SIGHUP handler(先收子进程组再清)+ atexit 兜底,
    成功/验签失败/清单非法/降级/解压失败/被中断全部删除暂存目录。

真机 time() 受 zig 0.17 mipsel time64 ABI 缺陷影响返回垃圾(实测 3381223399391169494、
localtime_r=NULL),故时间戳直取内核 o32 time32(syscall 4263)并自行换算 UTC,
不依赖 libc time()/localtime_r()。

upgrade 复用 install 同一实现(清单包名必须等于目标包名、目标必须已装)。

新增 host 回归 patch_v2/meowpkg/test_nyai.sh(49 断言:改名包/真实 0.1.7 反例/
footer≠清单/os.meow 无签名/第三方无签名/恶意包名/解压失败/SIGTERM/索引路径/upgrade)。

真机验证(固件 0.8.48,二进制只放 MeowOS/tmp,未覆盖 /usr/bin/nyai):
os.meow.reader-0.1.4.bak.mpk 现在「签名校验通过 (package=os.meow.reader)」后才被
降级门禁拒绝(旧版此处是包名不符);weird-name.bin 装成 dev.m35.probe;SIGTERM
中断后暂存目录为空;Music=199164544、/usr/bin/nyai md5 a7d24553… 均未变。
This commit is contained in:
AskaEth committed 2026-11-10 21:42:37 +08:00
1 parent 8f748b137d
commit 829d4475e8
4 files changed
+745 -147

No files matched your search

Binary file not shown.
+501 -147
View File
@@ -6,32 +6,54 @@
* - 源配置 MeowOS/opt/nyai/sources.list(每行一个源;local <路径> / http <url> / * - 源配置 MeowOS/opt/nyai/sources.list(每行一个源;local <路径> / http <url> /
* deb [trusted=yes] <uri> / file:// ; # 注释)。默认 local MeowOS/opt/repo。 * deb [trusted=yes] <uri> / file:// ; # 注释)。默认 local MeowOS/opt/repo。
* - 每源一个 Packages 索引(行式 Package/Version/Version-Code/Filename/Size/SHA256/Depends/Description), * - 每源一个 Packages 索引(行式 Package/Version/Version-Code/Filename/Size/SHA256/Depends/Description),
* 由 `nyai index <目录>` 生成。 * 由 `nyai index <目录>` 生成;Package 字段取自包内 app.json.package。
* - update 缓存索引到 MeowOS/opt/nyai/cache;install 按名称从索引取包(本地文件或 http 拉取), * - update 缓存索引到 MeowOS/opt/nyai/cache;install 按名称从索引取包(本地文件或 http 拉取),
* 处理 Depends(递归)、校验 SHA256 + app.json;同 name 先备份;解压 MeowAPPs/<pkg>/; * 处理 Depends(递归)、校验 SHA256 + app.json;同 name 先备份;解压 MeowAPPs/<pkg>/;
* 数据 init(MeowData/<pkg>/{data,cache})。exFAT 无符号链接 → 数据目录由环境变量注入,不建链接。 * 数据 init(MeowData/<pkg>/{data,cache})。exFAT 无符号链接 → 数据目录由环境变量注入,不建链接。
* *
* 包身份(M35):**包名/版本/依赖全部来自包内 app.json**,文件名与后缀只作路径,不参与身份判定。
* 安装流:解包到 SD 暂存目录 → 读 app.json(验签前,无循环依赖)→ 校验 footer.package 与
* app.json.package 一致 → 用清单包名调 meow_verify → 防降级 → rename 部署。
* 暂存目录 = <MeowROOT>/MeowOS/tmp/pkgs/<pkg>-<UTC 时间戳>-<pid>-<n>/,成功/失败/被信号中断都删。
*
* 路径前缀默认 /usr/data/mnt/sd_0;环境变量 MEOWROOT 可覆盖(仅本地 qemu/原生测试用,真机不设)。 * 路径前缀默认 /usr/data/mnt/sd_0;环境变量 MEOWROOT 可覆盖(仅本地 qemu/原生测试用,真机不设)。
*/ */
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <stdarg.h>
#include <string.h> #include <string.h>
#include <ctype.h> #include <ctype.h>
#include <unistd.h> #include <unistd.h>
#include <errno.h> #include <errno.h>
#include <fcntl.h>
#include <limits.h> #include <limits.h>
#include <dirent.h> #include <dirent.h>
#include <signal.h>
#include <stdint.h>
#include <time.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <sys/types.h> #include <sys/types.h>
#include <sys/wait.h> #include <sys/wait.h>
#define NYAI_VERSION "0.2.0" #define NYAI_VERSION "0.3.0"
#define BUF 1024 #define BUF 1024
#define MAX_SRC 16 #define MAX_SRC 16
#define MAX_PKG 256 #define MAX_PKG 256
#define MAX_DEPS 64 #define MAX_DEPS 64
#define MAX_DEPTH 32 #define MAX_DEPTH 32
/* 解包暂存目录根(相对 MeowROOT;真机 = /usr/data/mnt/sd_0/MeowOS/tmp/pkgs)。
* 每次安装一个子目录 <pkg>-<UTC 时间戳>-<pid>-<n>,无论成败都删。 */
#define STG_SUBDIR "MeowOS/tmp/pkgs"
/* .mpk 签名 footer 布局(与 patch_v2/meow_verify/meow_sig.h 同步;此处只读 package 用于
* 「footer.package vs app.json.package」一致性检查——真正的验签仍由 meow_verify 执行)。 */
#define SIG_MAGIC "MEOWSIG1"
#define SIG_PKG_MAX 64
#define SIG_BLOCK_LEN_V1 180
#define SIG_BLOCK_LEN_V2 184
#define SIG_OFF_PKG 12
/* ---------------- 路径 ---------------- */ /* ---------------- 路径 ---------------- */
static const char *meow_root(void) static const char *meow_root(void)
{ {
@@ -46,6 +68,8 @@ static void path_cache(char *out, int n)
{ snprintf(out,n,"%s/MeowOS/opt/nyai/cache", meow_root()); } { snprintf(out,n,"%s/MeowOS/opt/nyai/cache", meow_root()); }
static void path_repo_default(char *out, int n) static void path_repo_default(char *out, int n)
{ snprintf(out,n,"%s/MeowOS/opt/repo", meow_root()); } { snprintf(out,n,"%s/MeowOS/opt/repo", meow_root()); }
static void path_stg_base(char *out, int n)
{ snprintf(out,n,"%s/%s", meow_root(), STG_SUBDIR); }
/* ---------------- 源结构 ---------------- */ /* ---------------- 源结构 ---------------- */
typedef struct { typedef struct {
@@ -87,10 +111,11 @@ static void strip_comment(char *s)
if (p) *p = 0; if (p) *p = 0;
} }
/* 包名合法:仅 [A-Za-z0-9._-] */ /* 包名合法:仅 [A-Za-z0-9._-],且拒绝 "." / ".."(否则 MeowAPPs/<pkg> 会越出安装目录)。 */
static int valid_pkg(const char *s) static int valid_pkg(const char *s)
{ {
if (!s || !*s) return 0; if (!s || !*s) return 0;
if (!strcmp(s, ".") || !strcmp(s, "..")) return 0;
for (; *s; s++) for (; *s; s++)
if (!(isalnum((unsigned char)*s) || *s=='.' || *s=='-' || *s=='_')) return 0; if (!(isalnum((unsigned char)*s) || *s=='.' || *s=='-' || *s=='_')) return 0;
return 1; return 1;
@@ -100,6 +125,9 @@ static int unsafe_path(const char *s)
return s && strpbrk(s, " ;`$'\"&|<>") != NULL; return s && strpbrk(s, " ;`$'\"&|<>") != NULL;
} }
/* 前向声明:错误前缀(install/upgrade),定义见安装期安全设施一节。 */
static void op_err(const char *fmt, ...);
static int mkdir_p(const char *path) static int mkdir_p(const char *path)
{ {
char buf[BUF]; char buf[BUF];
@@ -156,6 +184,92 @@ static void human_size(long long b, char *out, int n)
else snprintf(out,n,"%.2f MB", b/(1024.0*1024)); else snprintf(out,n,"%.2f MB", b/(1024.0*1024));
} }
/* ---------------- 时钟 / 暂存目录 ---------------- */
/* 真机 time() 不可用:zig 0.17 自带 musl 在 mipsel 上把 time64 入口编成了 time32,
* 而 struct timespec 按 16 字节声明 → time() 返回「秒 + 纳秒<<32」的垃圾值
* (实测 time()=3381223399391169494,见 devdocs/CLOCK_ABI.md)。这里直接走内核 o32
* time32 ABI(SYS_clock_gettime=4263,缓冲 8 字节 {int32 sec,int32 nsec},真机实测正确)。
* 该时间只用于给人看的目录名,唯一性由 mkdir(2) 失败重试 + pid 保证。 */
static long long nyai_now(void)
{
#if defined(__mips__) && !defined(__mips64)
struct { int32_t tv_sec, tv_nsec; } tv = { 0, 0 };
if (syscall(4263, 0 /* CLOCK_REALTIME */, &tv) == 0 && tv.tv_sec > 0)
return (long long)tv.tv_sec;
return 0;
#else
return (long long)time(NULL);
#endif
}
/* UTC "YYYYmmdd-HHMMSS"(localtime_r 在真机同样受 time64 缺陷影响返回 NULL,故自己算)。 */
static void fmt_utc(long long sec, char *out, int n)
{
if (sec <= 0) { snprintf(out, n, "t0"); return; }
long long z = sec / 86400 + 719468; /* civil_from_days */
long long era = (z >= 0 ? z : z - 146096) / 146097;
unsigned doe = (unsigned)(z - era * 146097);
unsigned yoe = (doe - doe/1460 + doe/36524 - doe/146096) / 365;
long long y = (long long)yoe + era * 400;
unsigned doy = doe - (365*yoe + yoe/4 - yoe/100);
unsigned mp = (5*doy + 2)/153;
unsigned d = doy - (153*mp+2)/5 + 1;
unsigned m = mp + (mp < 10 ? 3 : 9); /* 1..12 */
if (m <= 2) y++;
long long rem = sec % 86400;
snprintf(out, n, "%04lld%02u%02u-%02u%02u%02u", y, m, d,
(unsigned)(rem/3600), (unsigned)((rem%3600)/60), (unsigned)(rem%60));
}
/* 路径是否正好是某个挂载点(读 /proc/mounts 第二列)。1=是 0=否 -1=读不到。 */
static int is_mount_point(const char *path)
{
FILE *f = fopen("/proc/mounts", "r");
if (!f) return -1;
char dev[256], mnt[512];
int hit = 0;
while (fscanf(f, "%255s %511s %*s %*s %*s %*s", dev, mnt) == 2) {
if (!strcmp(mnt, path)) { hit = 1; break; }
}
fclose(f);
return hit;
}
/* 准备暂存根:返回 0 成功;失败已打印原因并返回 -1。
* SD 未挂载 / 不可写时**拒绝安装**(不回退 /usr/data):安装目标 MeowAPPs 本身就在 SD 上,
* 卡不在装也没意义;且 /usr/data 只剩 ~4MB,跨文件系统 rebase 还会让部署 rename 变 EXDEV。 */
static int stg_prepare(char *base, int n)
{
path_stg_base(base, n);
/* 真机根(/usr/data/...)必须是真实挂载点;宿主/CI 的 MEOWROOT 沙箱不查。 */
const char *root = meow_root();
int dev_root = !strcmp(root, "/usr/data") || !strncmp(root, "/usr/data/", 10);
if (dev_root && is_mount_point(root) != 1) {
op_err("暂存目录 %s 不可用:SD 卡未挂载(%s 不是挂载点),拒绝安装\n", base, root);
return -1;
}
if (mkdir_p(base) != 0 || access(base, W_OK) != 0) {
op_err("暂存目录 %s 不可用(SD 卡只读/写满?): %s\n", base, strerror(errno));
return -1;
}
return 0;
}
/* 建暂存目录(解包前包名未知,用 .prep 前缀):mkdir(2) 原子占位 + 失败递增重试;
* 唯一性由 mkdir 的 EEXIST + pid + 序号保证,时间戳只给人看。 */
static int stg_mkdir(const char *base, char *out, int n)
{
char ts[32];
fmt_utc(nyai_now(), ts, sizeof ts);
for (int i = 0; i < 100; i++) {
if (snprintf(out, n, "%s/.prep-%s-%d-%d", base, ts, (int)getpid(), i) >= n) return -1;
if (mkdir(out, 0755) == 0) return 0;
if (errno != EEXIST) return -1;
}
return -1;
}
/* ---------------- JSON 读取 ---------------- */ /* ---------------- JSON 读取 ---------------- */
static int json_str(const char *j, const char *key, char *out, int n) static int json_str(const char *j, const char *key, char *out, int n)
{ {
@@ -288,8 +402,21 @@ static int load_app_meta(const char *path, appmeta *m)
return 0; return 0;
} }
/* 从 .mpk 里尽力读 app.json 字段(name/version/depends/description)。 /* 从 .mpk 里尽力读 app.json 字段(package/name/version/depends/description)。
* 用 tar -t 定位成员,再 tar -xO 取内容;失败返回 -1。 */ * 用 tar -t 定位成员,再 tar -xO 取内容;失败返回 -1。
* 成员名来自归档本身 → 保持原样('./app.json' 与 'app.json' 在 tar 里是不同名字),
* 但必须先做 shell/选项注入校验:禁前导 '-'(会被当成 tar 选项)、禁 shell 元字符、
* 禁控制字符、禁 '..'(index/scan 会对任意文件调用)。 */
static int member_safe_for_arg(const char *s)
{
if (!s || !*s || *s == '-') return 0;
if (unsafe_path(s)) return 0;
if (strstr(s, "..")) return 0;
for (const char *p = s; *p; p++)
if ((unsigned char)*p < 0x20 || *p == 0x7f) return 0;
return 1;
}
static int mpk_appinfo(const char *file, appmeta *m) static int mpk_appinfo(const char *file, appmeta *m)
{ {
memset(m, 0, sizeof *m); memset(m, 0, sizeof *m);
@@ -304,6 +431,7 @@ static int mpk_appinfo(const char *file, appmeta *m)
size_t mlen=strlen(mm); while (mlen&&(mm[mlen-1]=='\n'||mm[mlen-1]=='\r'||mm[mlen-1]==' ')) mm[--mlen]=0; size_t mlen=strlen(mm); while (mlen&&(mm[mlen-1]=='\n'||mm[mlen-1]=='\r'||mm[mlen-1]==' ')) mm[--mlen]=0;
pclose(pf); pclose(pf);
if (!mlen) return -1; if (!mlen) return -1;
if (!member_safe_for_arg(mm)) return -1;
snprintf(cmd,sizeof cmd,"gzip -dc '%s' 2>/dev/null | tar -xO '%s' 2>/dev/null", file, mm); snprintf(cmd,sizeof cmd,"gzip -dc '%s' 2>/dev/null | tar -xO '%s' 2>/dev/null", file, mm);
pf = popen(cmd, "r"); pf = popen(cmd, "r");
if (!pf) return -1; if (!pf) return -1;
@@ -313,6 +441,7 @@ static int mpk_appinfo(const char *file, appmeta *m)
if (!r) return -1; if (!r) return -1;
j[r]=0; j[r]=0;
if (json_str(j,"name",m->name,sizeof m->name)!=0 || !m->name[0]) return -1; if (json_str(j,"name",m->name,sizeof m->name)!=0 || !m->name[0]) return -1;
if (json_str(j,"package",m->package,sizeof m->package)!=0) m->package[0]=0;
if (json_str(j,"version",m->version,sizeof m->version)!=0) m->version[0]=0; if (json_str(j,"version",m->version,sizeof m->version)!=0) m->version[0]=0;
m->has_version_code = (json_int(j,"version_code",&m->version_code)==0); m->has_version_code = (json_int(j,"version_code",&m->version_code)==0);
if (json_str(j,"depends",m->depends,sizeof m->depends)!=0) m->depends[0]=0; if (json_str(j,"depends",m->depends,sizeof m->depends)!=0) m->depends[0]=0;
@@ -495,7 +624,7 @@ static int pkg_installed(const char *pkg)
return access(p, R_OK) == 0; return access(p, R_OK) == 0;
} }
static int install_mpk_file(const char *mpk_path); static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int must_be_installed);
/* 按记录安装一个索引包(含 sha256 校验 + 依赖),done 记录已装避免循环 */ /* 按记录安装一个索引包(含 sha256 校验 + 依赖),done 记录已装避免循环 */
static int install_rec(pkgrec *r, pkgrec *recs, int nrec, char (*done)[128], int ndone, int depth); static int install_rec(pkgrec *r, pkgrec *recs, int nrec, char (*done)[128], int ndone, int depth);
@@ -586,7 +715,7 @@ static int install_rec(pkgrec *r, pkgrec *recs, int nrec, char (*done)[128], int
return 1; return 1;
} }
} }
int rc = install_mpk_file(local); int rc = install_mpk_file(local, r->pkg, 0);
if (s->http) remove(local); if (s->http) remove(local);
return rc; return rc;
} }
@@ -624,9 +753,11 @@ static int cmd_update(void)
} }
/* ---------------- index <dir> ---------------- /* ---------------- index <dir> ----------------
* 索引的 Package 字段 = 包内 app.json.package(**不看文件名**;文件名只作 Filename 定位)。
* 清单缺 package / 非法 → 跳过并告警,不入索引(否则 install 时验签与落盘目录会错)。
* 注意(MPK_SIGNATURE_DESIGN §5.5):索引记录的 SHA256 = 整包哈希(含尾部签名 footer 的 .mpk)。 * 注意(MPK_SIGNATURE_DESIGN §5.5):索引记录的 SHA256 = 整包哈希(含尾部签名 footer 的 .mpk)。
* 必须先 `meow_sign sign <pkg>.mpk` 完成签名、再 `nyai index <dir>`,否则索引与装后的整包不一致, * 必须先 `meow_sign sign <pkg>.mpk` 完成签名、再 `nyai index <dir>`,否则索引与装后的整包不一致,
* install 的 sha256_file 校验会失败。本命令不改,仅标注此顺序约束(签名分级见 install_mpk_file)。 */ * install 的 sha256_file 校验会失败。 */
static int cmd_index(const char *dir) static int cmd_index(const char *dir)
{ {
if (!dir || !*dir) { fprintf(stderr,"nyai: index: 缺目录\n"); return 2; } if (!dir || !*dir) { fprintf(stderr,"nyai: index: 缺目录\n"); return 2; }
@@ -646,15 +777,16 @@ static int cmd_index(const char *dir)
if (L<4 || strcmp(de->d_name+L-4, ".mpk")!=0) continue; if (L<4 || strcmp(de->d_name+L-4, ".mpk")!=0) continue;
char fpath[BUF]; snprintf(fpath,sizeof fpath,"%s/%s", r, de->d_name); char fpath[BUF]; snprintf(fpath,sizeof fpath,"%s/%s", r, de->d_name);
struct stat st; if (stat(fpath,&st)!=0) continue; struct stat st; if (stat(fpath,&st)!=0) continue;
char stem[128]; snprintf(stem,sizeof stem,"%.*s", (int)(L-4), de->d_name);
appmeta m; appmeta m;
char ver[64]="", dep[256]="", desc[256]=""; char ver[64]="", dep[256]="", desc[256]="";
if (mpk_appinfo(fpath,&m)==0) { if (mpk_appinfo(fpath,&m)!=0 || !m.package[0] || !valid_pkg(m.package)) {
fprintf(stderr,"nyai: index: 跳过 %s(app.json 缺合法 package 字段)\n", de->d_name);
continue;
}
snprintf(ver,sizeof ver,"%s", m.version); snprintf(dep,sizeof dep,"%s", m.depends); snprintf(ver,sizeof ver,"%s", m.version); snprintf(dep,sizeof dep,"%s", m.depends);
snprintf(desc,sizeof desc,"%s", m.description[0]?m.description:m.name); snprintf(desc,sizeof desc,"%s", m.description[0]?m.description:m.name);
}
char sha[128]=""; sha256_file(fpath, sha, sizeof sha); char sha[128]=""; sha256_file(fpath, sha, sizeof sha);
fprintf(f,"Package: %s\n", stem); fprintf(f,"Package: %s\n", m.package);
fprintf(f,"Version: %s\n", ver[0]?ver:"0.0.0"); fprintf(f,"Version: %s\n", ver[0]?ver:"0.0.0");
if (m.has_version_code) fprintf(f,"Version-Code: %lld\n", m.version_code); if (m.has_version_code) fprintf(f,"Version-Code: %lld\n", m.version_code);
fprintf(f,"Filename: %s\n", de->d_name); fprintf(f,"Filename: %s\n", de->d_name);
@@ -663,7 +795,7 @@ static int cmd_index(const char *dir)
if (dep[0]) fprintf(f,"Depends: %s\n", dep); if (dep[0]) fprintf(f,"Depends: %s\n", dep);
if (desc[0]) fprintf(f,"Description: %s\n", desc); if (desc[0]) fprintf(f,"Description: %s\n", desc);
fprintf(f,"\n"); fprintf(f,"\n");
printf(" %s (%s)\n", stem, ver[0]?ver:"0.0.0"); printf(" %s (%s)\n", m.package, ver[0]?ver:"0.0.0");
n++; n++;
} }
fclose(f); fclose(f);
@@ -855,117 +987,362 @@ static void nyai_check_bin_depends(const appmeta *m)
} }
} }
/* ---------------- install(文件或名称) ---------------- */ /* ---------------- 安装期安全设施:信号清理 / 子进程 / 成员检查 / footer 读取 ---------------- */
static int install_mpk_file(const char *mpk_path) static const char *g_op = "install"; /* 当前操作名(错误前缀:install / upgrade) */
static char g_stg[BUF]; /* 当前暂存目录(空 = 无);所有退出路径都要清 */
static pid_t g_child = -1;
static volatile sig_atomic_t g_sig = 0;
static void op_err(const char *fmt, ...)
{ {
if (!mpk_path || !*mpk_path) { fprintf(stderr,"nyai: install: 缺 .mpk 路径\n"); return 2; } va_list ap;
if (access(mpk_path, R_OK)!=0) { fprintf(stderr,"nyai: install: 打不开 %s\n", mpk_path); return 1; } va_start(ap, fmt);
if (unsafe_path(mpk_path)) { fprintf(stderr,"nyai: install: 路径含非法字符\n"); return 1; } fprintf(stderr, "nyai: %s: ", g_op);
const char *bn = strrchr(mpk_path,'/'); vfprintf(stderr, fmt, ap);
bn = bn ? bn+1 : mpk_path; va_end(ap);
const char *dot = strrchr(bn,'.'); }
if (!dot || strcmp(dot,".mpk")!=0) { fprintf(stderr,"nyai: install: 需要 <pkg>.mpk\n"); return 1; }
char pkg[64]; /* 删掉本次的暂存目录(幂等;atexit / 信号 / 各失败分支共用)。 */
int len=(int)(dot-bn); static void stg_cleanup(void)
if (len<=0 || len>=(int)sizeof pkg) { fprintf(stderr,"nyai: install: 包名无效\n"); return 1; } {
memcpy(pkg,bn,len); pkg[len]=0; if (!g_stg[0]) return;
if (!valid_pkg(pkg)) { fprintf(stderr,"nyai: install: 包名含非法字符: %s\n", pkg); return 1; } rm_rf(g_stg);
g_stg[0] = 0;
}
/* SIGINT/SIGTERM/SIGHUP:先收掉自己的子进程组(gzip|tar 还在写暂存目录时不能直接删),
* 主流程在下一个检查点清理并退出(stg_check_signal)。 */
static void on_term(int sig)
{
g_sig = sig;
if (g_child > 0) kill(-g_child, SIGTERM);
}
/* 有挂起信号 → 清暂存目录并按惯例退出码退出。在所有阻塞步骤后调用。 */
static void stg_check_signal(void)
{
if (!g_sig) return;
if (g_stg[0]) op_err("收到信号 %d,已清理暂存目录 %s\n", (int)g_sig, g_stg);
stg_cleanup();
exit(128 + (int)g_sig);
}
/* 跑 shell 命令:子进程单独成组(setpgid),信号时整组收掉。
* 返回 0=成功;非 0=子进程退出码;-1=派生失败或被信号中断。 */
static int run_shell(const char *cmd)
{
pid_t pid = fork();
if (pid < 0) return -1;
if (pid == 0) {
setpgid(0, 0);
execl("/bin/sh", "sh", "-c", cmd, (char *)NULL);
_exit(127);
}
setpgid(pid, pid);
g_child = pid;
int st = 0;
for (;;) {
pid_t r = waitpid(pid, &st, 0);
if (r == pid) break;
if (r < 0 && errno == EINTR) { if (g_sig) kill(-pid, SIGTERM); continue; }
g_child = -1;
return -1;
}
g_child = -1;
if (g_sig) return -1;
if (!WIFEXITED(st)) return -1;
return WEXITSTATUS(st);
}
/* 把不可打印字节换成 '?'(错误信息回显归档内容时防终端控制字符)。 */
static void scrub(char *s)
{
for (; *s; s++) {
unsigned char c = (unsigned char)*s;
if (c < 0x20 || c == 0x7f) *s = '?';
}
}
/* 解包前逐条检查归档成员类型(tar -tv 首列):只允许普通文件 '-' 与目录 'd'。
* 符号链接/硬链接/设备/FIFO 一律拒绝(防「先建链、后续成员写穿到目录外」)。
* 名字穿越交由 tar 自身剥离(busybox 1.31 真机实测:'../x'→'x'、'/x'→'x');
* 解包后再用 stg_tree_ok() 复核暂存树。
* 返回 0 通过 / -2 成员类型非法 / -3 空归档 / -1 读失败。 */
static int mpk_check_members(const char *mpk)
{
char cmd[BUF];
snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -tv 2>/dev/null", mpk);
FILE *pf = popen(cmd, "r");
if (!pf) return -1;
char line[1200];
int n = 0, bad = 0;
while (fgets(line, sizeof line, pf)) {
n++;
if (line[0] != '-' && line[0] != 'd') {
char *e = line + strlen(line);
while (e > line && (e[-1] == '\n' || e[-1] == '\r')) *--e = 0;
op_err("包内含链接/特殊文件(仅允许普通文件与目录),拒绝安装: %s\n", line);
bad = 1;
break;
}
}
pclose(pf);
if (bad) return -2;
return n ? 0 : -3;
}
/* 解包后复核暂存树:只允许普通文件与目录;普通文件的 st_nlink 必须为 1
* (硬链接到外部文件会表现为 nlink>1)。0=通过。 */
static int stg_tree_ok(const char *path)
{
struct stat st;
if (lstat(path, &st) != 0) return 0;
if (S_ISDIR(st.st_mode)) {
DIR *d = opendir(path);
if (!d) return 0;
int ok = 1;
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name,".") || !strcmp(de->d_name,"..")) continue;
char p[BUF];
if (snprintf(p, sizeof p, "%s/%s", path, de->d_name) >= (int)sizeof p) { ok = 0; break; }
if (!stg_tree_ok(p)) { ok = 0; break; }
}
closedir(d);
return ok;
}
if (!S_ISREG(st.st_mode)) return 0;
return st.st_nlink <= 1;
}
/* 读 .mpk 尾部签名 footer 的 package 字段(布局见 meow_sig.h)。
* 返回 0=有 footer 且已填 out;1=无 footer(未签名);-1=读失败。
* 只用于一致性诊断;签名有效性始终由 meow_verify 判定。 */
static int read_footer_pkg(const char *mpk, char *out, int n)
{
int fd = open(mpk, O_RDONLY);
if (fd < 0) return -1;
if (n <= 0) { close(fd); return -1; }
off_t sz = lseek(fd, 0, SEEK_END);
if (sz < (off_t)SIG_BLOCK_LEN_V1) { close(fd); return 1; }
uint8_t b4[4];
if (lseek(fd, sz - 4, SEEK_SET) < 0 || read(fd, b4, 4) != 4) { close(fd); return -1; }
uint32_t blen = (uint32_t)b4[0] | ((uint32_t)b4[1] << 8) |
((uint32_t)b4[2] << 16) | ((uint32_t)b4[3] << 24);
if ((int)blen != SIG_BLOCK_LEN_V1 && (int)blen != SIG_BLOCK_LEN_V2) { close(fd); return 1; }
if (sz < (off_t)blen) { close(fd); return 1; }
uint8_t blk[SIG_BLOCK_LEN_V2];
if (lseek(fd, sz - (off_t)blen, SEEK_SET) < 0 ||
read(fd, blk, (size_t)blen) != (ssize_t)blen) { close(fd); return -1; }
close(fd);
if (memcmp(blk, SIG_MAGIC, 8) != 0) return 1;
int cl = 0;
while (cl < SIG_PKG_MAX && blk[SIG_OFF_PKG + cl]) cl++;
if (n <= cl) cl = n - 1;
memcpy(out, blk + SIG_OFF_PKG, (size_t)cl);
out[cl] = 0;
return 0;
}
/* 把 .prep-… 暂存目录改名成 <pkg>-<UTC 时间戳>-<pid>-<n>(rename 原子占名,
* EEXIST/ENOTEMPTY 换序号重试)。返回 0 成功。失败由调用方沿用 .prep 名。 */
static int stg_rename_pkg(const char *base, const char *prep, const char *pkg, char *out, int n)
{
char ts[32];
fmt_utc(nyai_now(), ts, sizeof ts);
for (int i = 0; i < 100; i++) {
if (snprintf(out, n, "%s/%s-%s-%d-%d", base, pkg, ts, (int)getpid(), i) >= n) return -1;
if (access(out, F_OK) == 0) continue;
if (rename(prep, out) == 0) return 0;
if (errno != EEXIST && errno != ENOTEMPTY) return -1;
}
return -1;
}
/* ---------------- install(文件或名称) ---------------- */
/* 安装/升级一个 .mpk。**包名、版本、依赖全部来自包内 app.json**,文件名与后缀不参与身份判定。
* expect_pkg != NULL → 清单包名必须等于它(upgrade / 索引安装);
* must_be_installed → 目标必须已安装(upgrade)。
* 暂存:<MeowROOT>/MeowOS/tmp/pkgs/<pkg>-<UTC>-<pid>-<n>/ ——成功/失败/被信号中断都删。 */
static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int must_be_installed)
{
struct stat fst;
char base[BUF], prep[BUF], final[BUF], stg[BUF], dst[BUF], bak[BUF], cmd[BUF * 2];
if (!mpk_path || !*mpk_path) { op_err("缺包文件路径\n"); return 2; }
if (unsafe_path(mpk_path)) { op_err("路径含非法字符\n"); return 1; }
if (stat(mpk_path, &fst) != 0 || !S_ISREG(fst.st_mode)) { op_err("打不开包文件 %s\n", mpk_path); return 1; }
if (stg_prepare(base, sizeof base) != 0) return 1;
printf("Reading package lists... Done\n"); printf("Reading package lists... Done\n");
char dst[BUF], bak[BUF], stg[BUF];
snprintf(dst,sizeof dst,"%s/MeowAPPs/%s", meow_root(), pkg);
snprintf(bak,sizeof bak,"%s.bak_%d", dst, (int)getpid());
snprintf(stg,sizeof stg,"%s/MeowAPPs/.stg_%d_%s", meow_root(), (int)getpid(), pkg);
int replacing = (access(dst,F_OK)==0);
/* 签名分级(II 期,MPK_SIGNATURE_DESIGN §5.2):有签名必须验签;os.meow.* 必须有有效签名; /* ① 暂存目录:包名要解包后才知道,先用 .prep-<时间戳>-<pid>-<n> 原子占位 */
* 无签名第三方放行(告警)。验签在 ① 解到 stg 后、② 防降级前执行(stg=临时目录,失败即 rm 不落盘)。 */ if (stg_mkdir(base, prep, sizeof prep) != 0) {
op_err("无法创建暂存目录(%s): %s\n", base, strerror(errno));
return 1;
}
snprintf(g_stg, sizeof g_stg, "%s", prep);
/* ① 先解到临时目录读元数据,再决定替换(防降级 + 校验 app.json) */ /* ② 解包前检查归档成员类型 */
rm_rf(stg); int mr = mpk_check_members(mpk_path);
if (mkdir_p(stg)!=0) { fprintf(stderr,"nyai: install: 无法建临时目录 %s\n", stg); return 1; } stg_check_signal();
char cmd[1024]; if (mr != 0) {
snprintf(cmd,sizeof cmd,"gzip -dc '%s' 2>/dev/null | tar -x -C '%s' 2>/dev/null && [ -f '%s/app.json' ]", mpk_path, stg, stg); if (mr == -3) op_err("%s 解不出内容(不是 .mpk / 空归档)\n", mpk_path);
printf("Preparing to unpack %s ...\n", pkg); else if (mr == -1) op_err("无法读取包内容(%s)\n", mpk_path);
if (system(cmd)!=0) { stg_cleanup();
fprintf(stderr,"nyai: install: 解压/校验 app.json 失败(%s 可能损坏或非 .mpk)\n", mpk_path); return 1;
rm_rf(stg); return 1; }
/* ③ 解包到暂存目录(SD 卡,不占 /usr/data 的 4MB NAND) */
printf("Preparing to unpack %s ...\n", mpk_path);
snprintf(cmd, sizeof cmd, "gzip -dc '%s' 2>/dev/null | tar -x -C '%s' 2>/dev/null", mpk_path, prep);
if (run_shell(cmd) != 0) {
stg_check_signal();
op_err("解压失败(%s 可能损坏或不是 .mpk)\n", mpk_path);
stg_cleanup();
return 1;
}
stg_check_signal();
if (!stg_tree_ok(prep)) {
op_err("解压结果含符号链接/硬链接等危险条目,拒绝安装\n");
stg_cleanup();
return 1;
}
char appj[BUF];
if (snprintf(appj, sizeof appj, "%s/app.json", prep) >= (int)sizeof appj) {
op_err("暂存路径过长\n"); stg_cleanup(); return 1;
} }
char stgj[BUF]; snprintf(stgj,sizeof stgj,"%s/app.json", stg);
appmeta m; appmeta m;
if (load_app_meta(stgj,&m)!=0) { if (load_app_meta(appj, &m) != 0) {
fprintf(stderr,"nyai: install: app.json 不合法(缺 name 或 exec)\n"); op_err("app.json 不合法(缺 name 或 exec)\n");
rm_rf(stg); return 1; stg_cleanup();
return 1;
} }
/* 二进制依赖检查(app.json depends):不阻断安装,缺 required 标注「安装后启动将失败」。 */
/* ④ 包名唯一来源:app.json.package(不再看文件名/后缀) */
if (!m.package[0]) {
op_err("app.json 缺 package 字段(包名只能来自清单),拒绝安装\n");
stg_cleanup();
return 1;
}
if (!valid_pkg(m.package)) {
op_err("app.json.package 非法: %s,拒绝安装\n", m.package);
stg_cleanup();
return 1;
}
if (expect_pkg && *expect_pkg && strcmp(m.package, expect_pkg) != 0) {
op_err("app.json.package(%s) 与目标包名(%s) 不一致,拒绝\n", m.package, expect_pkg);
stg_cleanup();
return 1;
}
const char *pkg = m.package;
/* 暂存目录改名成可读名 <pkg>-<时间戳>-<pid>-<n>;改名失败就沿用 .prep 名(不影响功能) */
snprintf(stg, sizeof stg, "%s", prep);
if (stg_rename_pkg(base, prep, pkg, final, sizeof final) == 0) {
snprintf(stg, sizeof stg, "%s", final);
snprintf(g_stg, sizeof g_stg, "%s", final);
}
/* ⑤ footer.package 必须等于 app.json.package(签名说 A、内容说 B → 拒装) */
{
char fpkg[SIG_PKG_MAX + 1];
int fr = read_footer_pkg(mpk_path, fpkg, sizeof fpkg);
if (fr == 0) {
scrub(fpkg);
if (strcmp(fpkg, pkg) != 0) {
op_err("签名包名(%s) 与 app.json(%s) 不一致,拒绝安装\n", fpkg, pkg);
stg_cleanup();
return 1;
}
}
}
/* ⑥ 签名分级:验签用清单包名;os.meow.* 必须有有效签名,无签名第三方放行 */
nyai_check_bin_depends(&m); nyai_check_bin_depends(&m);
/* ①.5 签名分级 + 三重绑定(MPK_SIGNATURE_DESIGN §3.3/§5.2 失败行为表) */
int vr = verify_mpk_signature(mpk_path, pkg, NULL, 0); int vr = verify_mpk_signature(mpk_path, pkg, NULL, 0);
stg_check_signal();
int system_pkg = is_system_pkg(pkg); int system_pkg = is_system_pkg(pkg);
int keep_src = 0; /* 有效签 → 安装后另存原签 .mpk.src */ int keep_src = 0; /* 有效签 → 安装后另存原签 .mpk.src */
if (vr == 0) { if (vr == 0) {
keep_src = 1; keep_src = 1;
printf("nyai: 签名校验通过 (package=%s)\n", pkg); printf("nyai: 签名校验通过 (package=%s)\n", pkg);
} else if (vr == 1) { } else if (vr == 1) {
fprintf(stderr,"nyai: install: 签名验证失败(内容被篡改/包名不符/公钥不符),拒绝安装\n"); op_err("签名验证失败(内容被篡改/包名不符/公钥不符),拒绝安装\n");
rm_rf(stg); return 1; stg_cleanup();
return 1;
} else if (vr == 2) { } else if (vr == 2) {
if (system_pkg) { if (system_pkg) {
fprintf(stderr,"nyai: install: 系统包 %s 必须签名(无签名),拒绝安装\n", pkg); op_err("系统包 %s 必须签名(无签名),拒绝安装\n", pkg);
rm_rf(stg); return 1; stg_cleanup();
return 1;
} }
printf("nyai: 提示: %s 未签名(第三方包放行安装)\n", pkg); printf("nyai: 提示: %s 未签名(第三方包放行安装)\n", pkg);
} else { } else {
if (system_pkg) { if (system_pkg) {
fprintf(stderr,"nyai: install: meow_verify 不可用(rc=%d),os.meow.* 拒装(宁缺勿装)\n", vr); op_err("meow_verify 不可用(rc=%d),os.meow.* 拒装(宁缺勿装)\n", vr);
rm_rf(stg); return 1; stg_cleanup();
return 1;
} }
printf("nyai: 警告: meow_verify 不可用(rc=%d),%s 未验签放行\n", vr, pkg); printf("nyai: 警告: meow_verify 不可用(rc=%d),%s 未验签放行\n", vr, pkg);
} }
/* 绑定(一般/②):解包 app.json.package == 文件名茎(防目录改名/改 app.json;meow_verify 已保证① footer.package==pkg) */
if (m.package[0] && strcmp(m.package, pkg) != 0) {
fprintf(stderr,"nyai: install: app.json.package(%s) != 包名(%s),拒绝\n", m.package, pkg);
rm_rf(stg); return 1;
}
/* ② 同包名替换前的防降级门禁(APPS.md §3.8,仅按 version_code 比较): /* ⑦ 落地目录 + 防降级(基准 = MeowAPPs/<清单包名> 的 app.json) */
* 新版 code >= 已装 code(相等=重装,允许;<即降级拒装);任一侧缺 version_code → 拒装。 */ stg_check_signal(); /* 从这里开始要改 MeowAPPs,临界区不再响应中断 */
if (snprintf(dst, sizeof dst, "%s/MeowAPPs/%s", meow_root(), pkg) >= (int)sizeof dst) {
op_err("包名过长\n"); stg_cleanup(); return 1;
}
snprintf(bak, sizeof bak, "%s.bak_%d", dst, (int)getpid());
int replacing = (access(dst, F_OK) == 0);
if (must_be_installed && !replacing) {
op_err("%s 未安装(如需全新安装请用 install)\n", pkg);
stg_cleanup();
return 1;
}
if (replacing) { if (replacing) {
char instj[BUF]; snprintf(instj,sizeof instj,"%s/app.json", dst); char instj[BUF];
appmeta im; appmeta im;
if (load_app_meta(instj,&im)!=0 || !m.has_version_code || !im.has_version_code) { snprintf(instj, sizeof instj, "%s/app.json", dst);
fprintf(stderr,"nyai: install: 版本号无法判定(新缺 version_code 或已装缺 version_code),拒绝安装\n"); if (load_app_meta(instj, &im) != 0 || !m.has_version_code || !im.has_version_code) {
rm_rf(stg); return 1; op_err("版本号无法判定(新缺 version_code 或已装缺 version_code),拒绝安装\n");
stg_cleanup();
return 1;
} }
if (m.version_code < im.version_code) { if (m.version_code < im.version_code) {
fprintf(stderr,"nyai: install: 不允许降级: 新版本 code %lld < 已装 code %lld\n", op_err("不允许降级: 新版本 code %lld < 已装 code %lld\n", m.version_code, im.version_code);
m.version_code, im.version_code); stg_cleanup();
rm_rf(stg); return 1; return 1;
} }
rm_rf(bak); rm_rf(bak);
if (rename(dst,bak)!=0) { fprintf(stderr,"nyai: install: 备份 %s 失败: %s\n", dst, strerror(errno)); rm_rf(stg); return 1; } if (rename(dst, bak) != 0) {
op_err("备份 %s 失败: %s\n", dst, strerror(errno));
stg_cleanup();
return 1;
}
printf("Backing up existing %s -> %s\n", pkg, bak); printf("Backing up existing %s -> %s\n", pkg, bak);
} else { } else {
rm_rf(dst); /* 防御:全新安装前清残留 */ rm_rf(dst); /* 防御:全新安装前清残留 */
} }
/* ③ 部署临时目录 -> 正式目录 */ /* ⑧ 部署:暂存目录与 MeowAPPs 同在 SD → rename 原子 */
if (rename(stg,dst)!=0) { if (rename(stg, dst) != 0) {
fprintf(stderr,"nyai: install: 部署 %s 失败: %s\n", dst, strerror(errno)); op_err("部署 %s 失败: %s\n", dst, strerror(errno));
rm_rf(stg); if (replacing) rename(bak,dst); /* 回滚到旧版 */ stg_cleanup();
if (replacing) rename(bak, dst); /* 回滚到旧版 */
return 1; return 1;
} }
g_stg[0] = 0; /* 已变成正式目录,不再属于清理范围 */
/* ④ 数据 init(MeowData 数据保留,仅保证目录存在) */ /* ⑨ 数据 init(MeowData 数据保留,仅保证目录存在) */
char d1[BUF],d2[BUF]; char d1[BUF], d2[BUF];
path_data(d1,sizeof d1,pkg,"data"); path_data(d2,sizeof d2,pkg,"cache"); path_data(d1, sizeof d1, pkg, "data");
path_data(d2, sizeof d2, pkg, "cache");
mkdir_p(d1); mkdir_p(d2); mkdir_p(d1); mkdir_p(d2);
printf("Setting up %s (%s) ...\n", pkg, m.version[0]?m.version:"?"); printf("Setting up %s (%s) ...\n", pkg, m.version[0] ? m.version : "?");
/* ④.5 II 期:原签 .mpk 另存 MeowData/<pkg>/data/<pkg>.mpk.src(导出保留签名) */ /* ⑩ II 期:原签 .mpk 另存 MeowData/<pkg>/data/<pkg>.mpk.src(导出保留签名) */
if (keep_src) save_mpk_source(mpk_path, pkg); if (keep_src) save_mpk_source(mpk_path, pkg);
/* ⑤ 安装成功后清理本次创建的 .bak_<pid>(失败回滚路径不动) */ /* ⑪ 安装成功后清理本次创建的 .bak_<pid>(失败回滚路径不动) */
if (replacing && bak[0]) { rm_rf(bak); printf("Removed backup %s\n", bak); } if (replacing && bak[0]) { rm_rf(bak); printf("Removed backup %s\n", bak); }
printf("OK\n"); printf("OK\n");
return 0; return 0;
@@ -973,21 +1350,21 @@ static int install_mpk_file(const char *mpk_path)
static int cmd_install(const char *arg) static int cmd_install(const char *arg)
{ {
if (!arg || !*arg) { fprintf(stderr,"nyai: install: 缺包名或 .mpk\n"); return 2; } if (!arg || !*arg) { op_err("缺包文件路径或索引包名\n"); return 2; }
/* ① 文件路径直接装 */ /* ① 存在的普通文件 → 直接装(文件名任意,身份来自包内 app.json) */
size_t L=strlen(arg); struct stat st;
if ((L>=4 && !strcmp(arg+L-4,".mpk")) && access(arg,R_OK)==0) { size_t L = strlen(arg);
return install_mpk_file(arg); int looks_file = (strchr(arg, '/') != NULL) ||
} (L >= 4 && !strcmp(arg + L - 4, ".mpk")) ||
if (!valid_pkg(arg)) { (access(arg, F_OK) == 0);
fprintf(stderr,"nyai: install: 非法包名: %s\n", arg); if (stat(arg, &st) == 0 && S_ISREG(st.st_mode)) return install_mpk_file(arg, NULL, 0);
return 1; if (looks_file) { op_err("打不开包文件 %s\n", arg); return 1; }
} if (!valid_pkg(arg)) { op_err("非法包名: %s\n", arg); return 1; }
/* ② 按索引装 */ /* ② 按索引装 */
pkgrec recs[MAX_PKG]; pkgrec recs[MAX_PKG];
int nrec = load_all_indexes(recs, MAX_PKG, g_srcs, g_nsrc); int nrec = load_all_indexes(recs, MAX_PKG, g_srcs, g_nsrc);
if (nrec == 0) { if (nrec == 0) {
fprintf(stderr,"nyai: 索引为空,先 nyai update 刷新(或直接 install <file>.mpk)\n"); op_err("索引为空,先 nyai update 刷新(或直接 install <包文件>)\n");
return 1; return 1;
} }
return install_by_name(arg, recs, nrec); return install_by_name(arg, recs, nrec);
@@ -1224,6 +1601,11 @@ static int cmd_export(const char *pkg, const char *out)
char appj[BUF]; snprintf(appj,sizeof appj,"%s/app.json", src); char appj[BUF]; snprintf(appj,sizeof appj,"%s/app.json", src);
appmeta m; appmeta m;
if (load_app_meta(appj,&m)!=0) { fprintf(stderr,"nyai: export: %s/app.json 不合法(缺 name 或 exec)\n", pkg); return 1; } if (load_app_meta(appj,&m)!=0) { fprintf(stderr,"nyai: export: %s/app.json 不合法(缺 name 或 exec)\n", pkg); return 1; }
/* 目录名必须等于清单包名(M35 后安装时已保证;老装目录缺 package 字段则跳过) */
if (m.package[0] && strcmp(m.package, pkg) != 0) {
fprintf(stderr,"nyai: export: 目录名(%s) 与 app.json.package(%s) 不一致,拒绝导出\n", pkg, m.package);
return 1;
}
/* 导出前体检:exec 文件存在且可执行。exec 可为绝对(包目录下,相对化)或相对路径。 */ /* 导出前体检:exec 文件存在且可执行。exec 可为绝对(包目录下,相对化)或相对路径。 */
char exec_rel[BUF]; char exec_rel[BUF];
if (exec_rel_to_pkg(src, m.exec, pkg, exec_rel, sizeof exec_rel)!=0) { if (exec_rel_to_pkg(src, m.exec, pkg, exec_rel, sizeof exec_rel)!=0) {
@@ -1270,13 +1652,12 @@ static int cmd_scan(const char *dir)
size_t L=strlen(de->d_name); size_t L=strlen(de->d_name);
if (L<4 || strcmp(de->d_name+L-4,".mpk")!=0) continue; if (L<4 || strcmp(de->d_name+L-4,".mpk")!=0) continue;
char f[BUF]; snprintf(f,sizeof f,"%s/%s", r, de->d_name); char f[BUF]; snprintf(f,sizeof f,"%s/%s", r, de->d_name);
char stem[128]; snprintf(stem,sizeof stem,"%.*s",(int)(L-4),de->d_name);
struct stat st; long long sz=0; if (stat(f,&st)==0) sz=st.st_size; struct stat st; long long sz=0; if (stat(f,&st)==0) sz=st.st_size;
char hs[24]; human_size(sz,hs,sizeof hs); char hs[24]; human_size(sz,hs,sizeof hs);
char nm[256]="", ver[64]=""; char ver[64]="";
appmeta m; appmeta m;
if (mpk_appinfo(f,&m)==0) { snprintf(nm,sizeof nm,"%s",m.name); snprintf(ver,sizeof ver,"%s",m.version); } if (mpk_appinfo(f,&m)==0) snprintf(ver,sizeof ver,"%s",m.version);
printf("%-24s %-10s %-10s %s\n", nm[0]?nm:stem, ver[0]?ver:"?", hs, de->d_name); printf("%-24s %-10s %-10s %s\n", m.package[0]?m.package:"?", ver[0]?ver:"?", hs, de->d_name);
found++; found++;
} }
closedir(d); closedir(d);
@@ -1285,56 +1666,20 @@ static int cmd_scan(const char *dir)
return 0; return 0;
} }
/* upgrade <pkg> [file]:目标包名 pkg 必须已安装;包文件内部清单的 package 必须等于 pkg。
* 其余(暂存/验签/防降级/部署/清理)全部走 install_mpk_file 同一实现,不再单独维护一套。 */
static int cmd_upgrade(const char *pkg, const char *file) static int cmd_upgrade(const char *pkg, const char *file)
{ {
if (!pkg || !valid_pkg(pkg)) { fprintf(stderr,"nyai: upgrade: 包名无效\n"); return 2; } if (!pkg || !valid_pkg(pkg)) { op_err("包名无效\n"); return 2; }
char mpk[BUF]; char dst[BUF], mpk[BUF];
if (file && *file) snprintf(mpk,sizeof mpk,"%s", file); if (snprintf(dst, sizeof dst, "%s/MeowAPPs/%s", meow_root(), pkg) >= (int)sizeof dst) {
else snprintf(mpk,sizeof mpk,"%s.mpk", pkg); op_err("包名过长\n"); return 1;
if (access(mpk,R_OK)!=0) { fprintf(stderr,"nyai: upgrade: 找不到包文件 %s\n", mpk); return 1; }
char dst[BUF]; snprintf(dst,sizeof dst,"%s/MeowAPPs/%s", meow_root(), pkg);
if (access(dst,F_OK)!=0) { fprintf(stderr,"nyai: upgrade: %s 未安装(如需全新安装请用 install)\n", pkg); return 1; }
/* 签名分级(与 install 同规范):os.meow.* 必须有有效签名;有签必须验签;无签第三方放行。 */
{
int vr = verify_mpk_signature(mpk, pkg, NULL, 0);
int sysp = is_system_pkg(pkg);
if (vr == 1) { fprintf(stderr,"nyai: upgrade: 签名验证失败,拒绝升级\n"); return 1; }
else if (vr == 2) { if (sysp) { fprintf(stderr,"nyai: upgrade: 系统包 %s 必须签名,拒绝升级\n", pkg); return 1; } printf("nyai: 提示: %s 未签名(第三方放行)\n", pkg); }
else if (vr != 0) { if (sysp) { fprintf(stderr,"nyai: upgrade: meow_verify 不可用(rc=%d),os.meow.* 拒升\n", vr); return 1; } printf("nyai: 警告: meow_verify 不可用(rc=%d),%s 未验签放行\n", vr, pkg); }
} }
/* 防降级门禁(与 install 同规范,APPS.md §3.8,仅按 version_code 比较): if (access(dst, F_OK) != 0) { op_err("%s 未安装(如需全新安装请用 install)\n", pkg); return 1; }
* 新版 code >= 已装 code(相等=重装允许);任一侧缺 version_code → 拒绝。 */ if (file && *file) snprintf(mpk, sizeof mpk, "%s", file);
{ else snprintf(mpk, sizeof mpk, "%s.mpk", pkg); /* 默认路径约定,不是身份来源 */
char aj[BUF]; snprintf(aj,sizeof aj,"%s/app.json", dst); if (access(mpk, R_OK) != 0) { op_err("找不到包文件 %s\n", mpk); return 1; }
appmeta im, nm; return install_mpk_file(mpk, pkg, 1);
if (load_app_meta(aj,&im)!=0 || mpk_appinfo(mpk,&nm)!=0 ||
!im.has_version_code || !nm.has_version_code) {
fprintf(stderr,"nyai: upgrade: 版本号无法判定(新缺 version_code 或已装缺 version_code),拒绝\n");
return 1;
}
if (nm.version_code < im.version_code) {
fprintf(stderr,"nyai: upgrade: 不允许降级: 新版本 code %lld < 已装 code %lld\n",
nm.version_code, im.version_code);
return 1;
}
}
char bak[BUF]; snprintf(bak,sizeof bak,"%s.bak_%d", dst,(int)getpid());
rm_rf(bak);
if (rename(dst,bak)!=0) { fprintf(stderr,"nyai: upgrade: 备份失败: %s\n", strerror(errno)); return 1; }
if (mkdir_p(dst)!=0) { rename(bak,dst); return 1; }
char cmd[1024];
snprintf(cmd,sizeof cmd,"gzip -dc '%s' 2>/dev/null | tar -x -C '%s' 2>/dev/null && [ -f '%s/app.json' ]", mpk,dst,dst);
printf("Upgrading %s ...\n", pkg);
if (system(cmd)!=0) { fprintf(stderr,"nyai: upgrade: 解压失败,回滚\n"); rm_rf(dst); rename(bak,dst); return 1; }
char appj[BUF]; snprintf(appj,sizeof appj,"%s/app.json", dst);
appmeta m;
if (load_app_meta(appj,&m)!=0) { fprintf(stderr,"nyai: upgrade: 新包 app.json 不合法,回滚\n"); rm_rf(dst); rename(bak,dst); return 1; }
char d1[BUF],d2[BUF];
path_data(d1,sizeof d1,pkg,"data"); path_data(d2,sizeof d2,pkg,"cache");
mkdir_p(d1); mkdir_p(d2);
if (bak[0]) { rm_rf(bak); } /* 升级成功后清理本次 .bak_<pid> */
printf("OK (%s) — data preserved\n", m.version[0]?m.version:"?");
return 0;
} }
/* ---------------- usage / main ---------------- */ /* ---------------- usage / main ---------------- */
@@ -1345,7 +1690,7 @@ static void usage(FILE *o, const char *argv0)
"\n" "\n"
"Usage:\n" "Usage:\n"
" %s update Read all sources into local index cache\n" " %s update Read all sources into local index cache\n"
" %s install <pkg|file.mpk> Install by index name or direct .mpk (resolves Depends)\n" " %s install <file|pkg> Install a package file (any filename) or an index name\n"
" %s remove <pkg> [--purge] Uninstall (--purge also deletes MeowData)\n" " %s remove <pkg> [--purge] Uninstall (--purge also deletes MeowData)\n"
" %s list List installed apps (MeowAPPs/*)\n" " %s list List installed apps (MeowAPPs/*)\n"
" %s info <pkg> Show package details (app.json)\n" " %s info <pkg> Show package details (app.json)\n"
@@ -1354,23 +1699,31 @@ static void usage(FILE *o, const char *argv0)
" %s index <dir> Generate Packages index for a .mpk dir\n" " %s index <dir> Generate Packages index for a .mpk dir\n"
" %s export <pkg> [out.mpk] Pack MeowAPPs/<pkg> -> <pkg>.mpk (no data)\n" " %s export <pkg> [out.mpk] Pack MeowAPPs/<pkg> -> <pkg>.mpk (no data)\n"
" %s scan [dir] List .mpk in dir (default .)\n" " %s scan [dir] List .mpk in dir (default .)\n"
" %s upgrade <pkg> [file.mpk] Upgrade app, keep MeowData\n" " %s upgrade <pkg> [file] Upgrade installed <pkg>, keep MeowData\n"
" %s --help This help\n" " %s --help This help\n"
"\n" "\n"
"Package identity (name/version/depends) comes from the app.json inside the package;\n"
"the file name is irrelevant. install/upgrade unpack to %s/MeowOS/tmp/pkgs/ and always\n"
"delete that staging directory (success, failure, SIGINT/SIGTERM).\n"
"\n"
"Sources: %s/MeowOS/opt/nyai/sources.list (local <dir> / http <url> / deb [trusted=yes] <uri>; # comment)\n" "Sources: %s/MeowOS/opt/nyai/sources.list (local <dir> / http <url> / deb [trusted=yes] <uri>; # comment)\n"
"Exit codes: 0 success, non-zero error.\n", "Exit codes: 0 success, non-zero error.\n",
NYAI_VERSION, argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0, NYAI_VERSION, argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,
meow_root()); meow_root(), meow_root());
} }
int main(int argc, char **argv) int main(int argc, char **argv)
{ {
int sigs[] = { SIGINT, SIGTERM, SIGHUP };
for (unsigned i = 0; i < sizeof sigs / sizeof sigs[0]; i++) signal(sigs[i], on_term);
atexit(stg_cleanup); /* 任何 return/exit 路径的兜底清理 */
if (argc<2) { usage(stderr, argv[0]); return 1; } if (argc<2) { usage(stderr, argv[0]); return 1; }
const char *cmd = argv[1]; const char *cmd = argv[1];
if (!strcmp(cmd,"--help")||!strcmp(cmd,"-h")) { usage(stdout, argv[0]); return 0; } if (!strcmp(cmd,"--help")||!strcmp(cmd,"-h")) { usage(stdout, argv[0]); return 0; }
if (!strcmp(cmd,"update")) { return cmd_update(); } if (!strcmp(cmd,"update")) { return cmd_update(); }
if (!strcmp(cmd,"install")) { if (!strcmp(cmd,"install")) {
if (argc<3) { fprintf(stderr,"nyai: install: 缺包名或 .mpk\n"); return 2; } if (argc<3) { op_err("缺包文件路径或索引包名\n"); return 2; }
load_sources(); load_sources();
return cmd_install(argv[2]); return cmd_install(argv[2]);
} }
@@ -1396,7 +1749,8 @@ int main(int argc, char **argv)
} }
if (!strcmp(cmd,"scan")) return cmd_scan(argc>2?argv[2]:NULL); if (!strcmp(cmd,"scan")) return cmd_scan(argc>2?argv[2]:NULL);
if (!strcmp(cmd,"upgrade")) { if (!strcmp(cmd,"upgrade")) {
if (argc<3) { fprintf(stderr,"nyai: upgrade: 缺包名\n"); return 2; } g_op = "upgrade";
if (argc<3) { op_err("缺包名\n"); return 2; }
return cmd_upgrade(argv[2], argc>3?argv[3]:NULL); return cmd_upgrade(argv[2], argc>3?argv[3]:NULL);
} }
fprintf(stderr,"nyai: 未知命令 '%s'(--help 查看用法)\n", cmd); fprintf(stderr,"nyai: 未知命令 '%s'(--help 查看用法)\n", cmd);
Binary file not shown.
+244
View File
@@ -0,0 +1,244 @@
#!/bin/bash
# test_nyai.sh — nyai 宿主回归(M35:app.json 清单权威 + SD 暂存 + 无条件清理)
#
# 覆盖:
# 1 文件名/后缀与包名完全不同(weird-name.bin)仍按清单装进 MeowAPPs/<app.json.package>
# 2 真实反例:签名 os.meow.reader 包命名成 os.meow.reader-0.1.7.mpk 仍装进 MeowAPPs/os.meow.reader
# 3 footer.package ≠ app.json.package → 拒装,错误文案同时给出两个名字
# 4 os.meow.* 无签名 → 拒装
# 5 第三方无签名 → 放行(行为不变)
# 6 恶意 app.json.package(../x、a/b、.、..)→ 拒装且不落盘到目录外
# 7 成功 / 各种失败 / 解压失败 / SIGTERM 中断 → MeowOS/tmp/pkgs 无残留
# 8 索引路径:Packages 的 Package 字段取自清单(文件改名后按包名仍可装)
# 9 upgrade 复用 install 引擎:清单包名/目标包名不一致、降级都拒
#
# 依赖:宿主 cc(编 nyai_host / meow_verify_host / meow_sign)。
# 签名用例需要维护者私钥:默认 ~/.keys/meowos-signing/signing.key,
# 可用 NYAI_TEST_SIGN_KEY 覆盖;无私钥时签名用例 SKIP(不判 FAIL)。
# 真实反例包默认 ~/.cache/os.meow.reader-0.1.7-signed.mpk,可用 NYAI_TEST_REAL_MPK 覆盖。
#
# 用法:./test_nyai.sh [-v]
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
VERBOSE=0
[ "${1:-}" = "-v" ] && VERBOSE=1
WORK=${NYAI_TEST_WORK:-$(mktemp -d "${TMPDIR:-/tmp}/nyai-m35.XXXXXX")}
mkdir -p "$WORK"
CLEANUP_WORK=1
[ -n "${NYAI_TEST_WORK:-}" ] && CLEANUP_WORK=0
trap '[ "$CLEANUP_WORK" = 1 ] && rm -rf "$WORK"' EXIT
PASS=0; FAIL=0; SKIP=0
ok() { PASS=$((PASS+1)); printf 'PASS %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf 'FAIL %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; }
skip() { SKIP=$((SKIP+1)); printf 'SKIP %s (%s)\n' "$1" "${2:-}"; }
KEY=${NYAI_TEST_SIGN_KEY:-$HOME/.keys/meowos-signing/signing.key}
[ -r "$KEY" ] || KEY=""
REAL_MPK=${NYAI_TEST_REAL_MPK:-$HOME/.cache/os.meow.reader-0.1.7-signed.mpk}
# ---------------- 构建 ----------------
echo "== build =="
( cd "$HERE" && ./build_nyai.sh host >/dev/null ) || { echo "build nyai_host failed"; exit 1; }
NYAI="$HERE/nyai_host"
CC=${CC_HOST:-cc}
V="$ROOT/patch_v2/meow_verify"
mkdir -p "$WORK/tools"
"$CC" -Os -Wno-sign-compare -Wno-unterminated-string-initialization -Wno-unused-function \
-c "$V/tweetnacl.c" -o "$WORK/tools/tn.o" || { echo "build tweetnacl failed"; exit 1; }
"$CC" -Os -c "$V/sha256.c" -o "$WORK/tools/sha.o" || exit 1
"$CC" -Os -I"$V" -c "$V/meow_verify.c" -o "$WORK/tools/mv.o" || exit 1
"$CC" "$WORK/tools/mv.o" "$WORK/tools/sha.o" "$WORK/tools/tn.o" -o "$WORK/tools/meow_verify_host" || exit 1
if [ -n "$KEY" ]; then
"$CC" -Os -I"$V" -I"$ROOT/patch_v2/meow_sign" "$ROOT/patch_v2/meow_sign/meow_sign.c" \
"$WORK/tools/tn.o" "$WORK/tools/sha.o" -o "$WORK/tools/meow_sign" || exit 1
fi
VERIFY="$WORK/tools/meow_verify_host"
# ---------------- 辅助 ----------------
sb() { # sb <name> → 打印沙箱 MeowROOT
local d="$WORK/case-$1"
rm -rf "$d"
mkdir -p "$d/root/MeowAPPs" "$d/root/MeowOS/opt/nyai"
printf '%s\n' "$d/root"
}
mkpkg() { # mkpkg <dir> <package> <version_code>
rm -rf "$1"; mkdir -p "$1"
printf '{"name":"Test","package":"%s","exec":"bin","version":"0.1.%s","version_code":%s}\n' \
"$2" "$3" "$3" > "$1/app.json"
printf '#!/bin/sh\nexit 0\n' > "$1/bin"; chmod +x "$1/bin"
}
pack() { tar czf "$2" -C "$1" .; }
sign() { # sign <pkg> <mpk>:文件名茎必须等于包名(meow_sign 约定),签完覆盖原文件
[ -n "$KEY" ] || return 2
mkdir -p "$WORK/sign"
cp "$2" "$WORK/sign/$1.mpk"
"$WORK/tools/meow_sign" sign "$WORK/sign/$1.mpk" "$KEY" >/dev/null 2>&1 || return 1
cp "$WORK/sign/$1.mpk" "$2"
}
nyai_run() { # nyai_run <root> <args...>
local r=$1; shift
MEOWROOT="$r" MEOW_VERIFY_BIN="$VERIFY" "$NYAI" "$@"
}
tmp_clean() { # tmp_clean <root>:MeowOS/tmp/pkgs 下无任何条目
local n
n=$(find "$1/MeowOS/tmp/pkgs" -mindepth 1 2>/dev/null | wc -l | tr -d ' ')
[ "$n" = "0" ]
}
show() { [ "$VERBOSE" = 1 ] && printf '%s\n' "$1"; }
expect() { # expect <label> <cond...>
local l=$1; shift
if "$@"; then ok "$l"; else bad "$l"; fi
}
echo "== tests =="
# ---- 1 文件名与包名完全不同 ----
r=$(sb 1); mkpkg "$WORK/b1" os.meow.demo 1; pack "$WORK/b1" "$WORK/p1.mpk"
if sign os.meow.demo "$WORK/p1.mpk"; then
cp "$WORK/p1.mpk" "$WORK/weird-name.bin"
out=$(nyai_run "$r" install "$WORK/weird-name.bin" 2>&1); rc=$?
show "$out"
expect "1a weird-name.bin(无 .mpk 后缀)按清单装入 MeowAPPs/os.meow.demo" \
test $rc -eq 0 -a -f "$r/MeowAPPs/os.meow.demo/app.json"
expect "1b 未按文件名生成目录" test ! -e "$r/MeowAPPs/weird-name"
expect "1c 成功后 MeowOS/tmp/pkgs 无残留" tmp_clean "$r"
else
skip "1 文件名解绑(签名)" "无签名私钥"
fi
# ---- 2 真实反例:reader 包改名 ----
if [ -r "$REAL_MPK" ]; then
r=$(sb 2)
cp "$REAL_MPK" "$WORK/os.meow.reader-0.1.7.mpk"
out=$(nyai_run "$r" install "$WORK/os.meow.reader-0.1.7.mpk" 2>&1); rc=$?
show "$out"
expect "2a os.meow.reader-0.1.7.mpk 仍装进 MeowAPPs/os.meow.reader(不再按文件名取包名)" \
test $rc -eq 0 -a -f "$r/MeowAPPs/os.meow.reader/app.json"
expect "2b 没有生成 MeowAPPs/os.meow.reader-0.1.7" test ! -e "$r/MeowAPPs/os.meow.reader-0.1.7"
expect "2c 成功后无暂存残留" tmp_clean "$r"
else
skip "2 真实 reader 包改名安装" "缺 $REAL_MPK"
fi
# ---- 3 footer.package ≠ app.json.package ----
if [ -n "$KEY" ]; then
r=$(sb 3); mkpkg "$WORK/b3" os.meow.mismatch-b 1; pack "$WORK/b3" "$WORK/mis-a.mpk"
# 签名时文件名茎 = 包名 → footer 写 os.meow.mismatch-a,清单里却是 os.meow.mismatch-b
sign os.meow.mismatch-a "$WORK/mis-a.mpk"
out=$(nyai_run "$r" install "$WORK/mis-a.mpk" 2>&1); rc=$?
show "$out"
expect "3a footer.package ≠ app.json.package → 拒装" test $rc -ne 0
expect "3b 错误文案含两个包名与「不一致」" \
sh -c 'printf "%s" "$1" | grep -q "签名包名(os.meow.mismatch-a) 与 app.json(os.meow.mismatch-b) 不一致"' _ "$out"
expect "3c 拒装后未落盘任何 App" test ! -e "$r/MeowAPPs/os.meow.mismatch-b" -a ! -e "$r/MeowAPPs/os.meow.mismatch-a"
expect "3d 拒装后无暂存残留" tmp_clean "$r"
else
skip "3 footer/清单不一致" "无签名私钥"
fi
# ---- 4 os.meow.* 无签名 ----
r=$(sb 4); mkpkg "$WORK/b4" os.meow.unsigned 1; pack "$WORK/b4" "$WORK/unsigned-sys.mpk"
out=$(nyai_run "$r" install "$WORK/unsigned-sys.mpk" 2>&1); rc=$?
show "$out"
expect "4a os.meow.* 无签名 → 拒装" test $rc -ne 0
expect "4b 错误文案含「必须签名」" sh -c 'printf "%s" "$1" | grep -q "必须签名"' _ "$out"
expect "4c 未落盘 + 无残留" test ! -e "$r/MeowAPPs/os.meow.unsigned"
expect "4d 拒装后无暂存残留" tmp_clean "$r"
# ---- 5 第三方无签名放行 ----
r=$(sb 5); mkpkg "$WORK/b5" dev.local.thirdparty 1; pack "$WORK/b5" "$WORK/thirdparty.mpk"
out=$(nyai_run "$r" install "$WORK/thirdparty.mpk" 2>&1); rc=$?
show "$out"
expect "5a 第三方无签名 → 放行安装(行为不变)" \
test $rc -eq 0 -a -f "$r/MeowAPPs/dev.local.thirdparty/app.json"
expect "5b 成功后无残留" tmp_clean "$r"
# ---- 6 恶意 package 名 ----
for badname in "../escape" "a/b" "." ".."; do
r=$(sb 6)
mkpkg "$WORK/b6" "$badname" 1; pack "$WORK/b6" "$WORK/evil.mpk"
out=$(nyai_run "$r" install "$WORK/evil.mpk" 2>&1); rc=$?
show "$out"
expect "6 [$badname] 拒装" test $rc -ne 0
expect "6 [$badname] 错误文案指向 app.json.package" \
sh -c 'printf "%s" "$1" | grep -q "app.json.package 非法"' _ "$out"
expect "6 [$badname] 未落盘到目录外(root/escape、root/a、沙箱父目录)" \
test ! -e "$r/escape" -a ! -e "$r/a" -a ! -e "$WORK/case-6/escape" -a ! -e "$WORK/case-6/a"
expect "6 [$badname] MeowAPPs/MeowOS 目录未被破坏" \
test -d "$r/MeowAPPs" -a -d "$r/MeowOS"
expect "6 [$badname] 无暂存残留" tmp_clean "$r"
done
# ---- 7 解压失败 / SIGTERM 中断 ----
r=$(sb 7)
printf 'this is not a gzip stream\n' > "$WORK/broken.mpk"
out=$(nyai_run "$r" install "$WORK/broken.mpk" 2>&1); rc=$?
show "$out"
expect "7a 非法包(非 gzip/tar)→ 拒装" test $rc -ne 0
expect "7b 解压失败后无暂存残留" tmp_clean "$r"
mkpkg "$WORK/b7" dev.test.slow 1
head -c 134217728 /dev/zero > "$WORK/b7/big.bin" # 128MB,拉长解包时间以便打信号
pack "$WORK/b7" "$WORK/slow.mpk"
r=$(sb 7s)
MEOWROOT="$r" MEOW_VERIFY_BIN="$VERIFY" "$NYAI" install "$WORK/slow.mpk" >"$WORK/sig.log" 2>&1 &
pid=$!
sleep 0.30
kill -TERM "$pid" 2>/dev/null
wait "$pid"; rc=$?
show "$(cat "$WORK/sig.log")"
if grep -q "收到信号" "$WORK/sig.log"; then
expect "7c SIGTERM 中断:退出码 143" test "$rc" -eq 143
expect "7d SIGTERM 中断后无暂存残留" tmp_clean "$r"
expect "7e SIGTERM 中断后未落盘 App" test ! -e "$r/MeowAPPs/dev.test.slow"
else
skip "7c-e SIGTERM 中断清理" "解包在信号前已完成(rc=$rc)"
fi
rm -f "$WORK/b7/big.bin"
# ---- 8 索引路径:Package 取自清单 ----
r=$(sb 8)
mkdir -p "$WORK/repo"
mkpkg "$WORK/b8" os.meow.idx 3; pack "$WORK/b8" "$WORK/idx.mpk"
if sign os.meow.idx "$WORK/idx.mpk"; then
cp "$WORK/idx.mpk" "$WORK/repo/zzz-renamed-file.mpk" # 文件名与包名完全不同
printf 'local %s\n' "$WORK/repo" > "$r/MeowOS/opt/nyai/sources.list"
"$NYAI" index "$WORK/repo" >"$WORK/idx.log" 2>&1
expect "8a 索引 Package 字段来自 app.json(不是文件名)" \
grep -q '^Package: os.meow.idx$' "$WORK/repo/Packages"
out=$(nyai_run "$r" install os.meow.idx 2>&1); rc=$?
show "$out"
expect "8b 按索引包名安装成功" test $rc -eq 0 -a -f "$r/MeowAPPs/os.meow.idx/app.json"
expect "8c 索引安装后无残留" tmp_clean "$r"
else
skip "8 索引路径" "无签名私钥"
fi
# ---- 9 upgrade 复用 install 引擎 ----
r=$(sb 9)
mkpkg "$WORK/b9" dev.local.up 1; pack "$WORK/b9" "$WORK/up1.mpk"
nyai_run "$r" install "$WORK/up1.mpk" >/dev/null 2>&1
mkpkg "$WORK/b9" dev.local.up 2; pack "$WORK/b9" "$WORK/up2-any-name.bin"
out=$(nyai_run "$r" upgrade dev.local.up "$WORK/up2-any-name.bin" 2>&1); rc=$?
show "$out"
expect "9a upgrade 接受任意文件名(清单包名 == 目标包名)" \
test $rc -eq 0 -a "$(grep -o '"version_code":[0-9]*' "$r/MeowAPPs/dev.local.up/app.json")" = '"version_code":2'
expect "9b upgrade 后无残留" tmp_clean "$r"
mkpkg "$WORK/b9" dev.local.up 0; pack "$WORK/b9" "$WORK/up0.mpk"
out=$(nyai_run "$r" upgrade dev.local.up "$WORK/up0.mpk" 2>&1); rc=$?
show "$out"
expect "9c 降级被拒" sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "不允许降级"' _ "$rc" "$out"
mkpkg "$WORK/b9" dev.local.other 3; pack "$WORK/b9" "$WORK/upother.mpk"
out=$(nyai_run "$r" upgrade dev.local.up "$WORK/upother.mpk" 2>&1); rc=$?
show "$out"
expect "9d 清单包名 ≠ upgrade 目标 → 拒" \
sh -c 'test $1 -ne 0 && printf "%s" "$2" | grep -q "与目标包名(dev.local.up) 不一致"' _ "$rc" "$out"
expect "9e 以上失败均无残留" tmp_clean "$r"
# ---------------- 汇总 ----------------
printf '\n== summary: %d passed, %d failed, %d skipped ==\n' "$PASS" "$FAIL" "$SKIP"
[ "$FAIL" -eq 0 ] || exit 1
exit 0