Files
MeowOS-For-R3P2/patch_v2/meowpkg/nyai.c
T
AskaEth cf5703b32b fix(nyai): M35 评审 r1 — 闭合 exFAT 大小写绕过(P1)+ 修 fmt_utc 1/2 月 + 成员名/炸弹纵深
P1(信任边界):is_system_pkg() 原用 strncmp,而落盘目录在 exFAT(名字比较
不区分大小写)。包内 app.json 写 "OS.MEOW.READER" 时 valid_pkg 放行、
is_system_pkg 返回 0 → 当第三方无签放行 → dst 解析到已装的
MeowAPPs/os.meow.reader → 备份后部署假包并删掉真系统 App(无需私钥)。
三层封堵:
  1) is_system_pkg 改 strncasecmp(前缀判定不再受大小写影响);
  2) valid_pkg 收紧为只收 [a-z0-9._-],并拒绝前导/尾随 '.'(现网包名全小写,
     无兼容代价;tail-dot 同时挡 exFAT/Windows 名字映射怪癖);
  3) 部署前 readdir 检查 MeowAPPs 里是否有与 pkg 仅大小写不同的已装目录
     (有则拒装并报两个名字);部署后再用 readdir 复核 on-disk 名字与清单包名
     逐字节相等,不等则回滚,杜绝「身份不明的 App 目录」。

P2-1:fmt_utc() 的 March-based 月份换算写成 +9(应为 -9),1/2 月会打成
20251901/20251931 且年份不 +1。修正为 int 运算;新增 test_fmt_utc.c
(直接 include nyai.c 测 static 函数)覆盖 epoch/负数、1/1、2/1、2/28、闰年
2/29(2024 与 2000)、3/1、12/31 23:59:59、2038-01-01、int32 上界、2100 非闰。

纵深防御(评审残留风险,一并做):
- tar 成员名净化不再依赖外部 tar:mpk_check_members 解析 tar -tv 的名字,
  拒绝绝对路径 / 含 '..' 组件 / 首字符 '-' / 原始控制字符;解包后 stg_tree_ok
  再用 readdir 的真实名字复核控制字符(tar 列名会把控制字符转义成可见文本,
  原检查会漏)。
- 解压炸弹:累加 tar -tv 的体积,超过 512MB 上限拒装,并加 statvfs
  「剩余空间 ≥ 体积 + 16MB」门禁(解包先于验签,恶意包可在验签前写满卡)。
- 顺手改掉 appmeta.package 注释里「== 文件名茎」的旧语义。

回归:test_nyai.sh 49 → 70 条断言全过(新增:fmt_utc 13 例、大写包名绕过顶替
已装系统包被拒且原 App 未变、尾点包名、与已装目录仅大小写不同、绝对路径/
../ / 首字符 '-' 成员名、控制字符成员名、700MB 解压炸弹),
`-Wall -Wextra` 零告警,二进制可重复构建。

交付:nyai 153,844 B / md5 ef7940ecf5ece993d718ac1d9019cc82(旧 4524ce6b…)。
真机 10.58.215.50 仍不可登录(22 端口 TCP 可连、15-50s 无 banner),本轮
**未做真机复核**,仅宿主验证。
2026-11-10 21:42:37 +08:00

1932 lines
78 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* nyai.c — MeowOS apt/yum 风格软件包管理 CLI(静态 MIPS32r2, musl, 无外部依赖)
*
* 与 meow_launcherd 的 meow_pkg_install()/export() 语义一致(ANALYSIS.md §18.38 / M2.12;
* APPS.md 附录 v2)。支持镜像源:
* - 源配置 MeowOS/opt/nyai/sources.list(每行一个源;local <路径> / http <url> /
* deb [trusted=yes] <uri> / file:// ; # 注释)。默认 local MeowOS/opt/repo。
* - 每源一个 Packages 索引(行式 Package/Version/Version-Code/Filename/Size/SHA256/Depends/Description),
* 由 `nyai index <目录>` 生成;Package 字段取自包内 app.json.package。
* - update 缓存索引到 MeowOS/opt/nyai/cache;install 按名称从索引取包(本地文件或 http 拉取),
* 处理 Depends(递归)、校验 SHA256 + app.json;同 name 先备份;解压 MeowAPPs/<pkg>/;
* 数据 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/原生测试用,真机不设)。
*/
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <strings.h>
#include <ctype.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <limits.h>
#include <dirent.h>
#include <signal.h>
#include <stdint.h>
#include <time.h>
#include <sys/stat.h>
#include <sys/statvfs.h>
#include <sys/types.h>
#include <sys/wait.h>
#define NYAI_VERSION "0.3.1"
#define BUF 1024
#define MAX_SRC 16
#define MAX_PKG 256
#define MAX_DEPS 64
#define MAX_DEPTH 32
/* 解包暂存目录根(相对 MeowROOT;真机 = /usr/data/mnt/sd_0/MeowOS/tmp/pkgs)。
* 每次安装一个子目录 <pkg>-<UTC 时间戳>-<pid>-<n>,无论成败都删。 */
#define STG_SUBDIR "MeowOS/tmp/pkgs"
/* 单包解压后体积上限(防解压炸弹:解包先于验签,恶意包可以在验签失败前把卡写满)。
* 现网最大 App ≈ 6MB(ncm),512MB 留足余量;另加 statvfs 余量检查。 */
#define MAX_UNPACK_BYTES (512LL * 1024 * 1024)
#define STG_SPACE_MARGIN (16LL * 1024 * 1024)
/* .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)
{
const char *r = getenv("MEOWROOT");
return (r && *r) ? r : "/usr/data/mnt/sd_0";
}
static void path_data(char *out, int n, const char *pkg, const char *sub)
{ snprintf(out, n, "%s/MeowData/%s/%s", meow_root(), pkg ? pkg : "", sub ? sub : ""); }
static void path_sources(char *out, int n)
{ snprintf(out,n,"%s/MeowOS/opt/nyai/sources.list", meow_root()); }
static void path_cache(char *out, int n)
{ snprintf(out,n,"%s/MeowOS/opt/nyai/cache", meow_root()); }
static void path_repo_default(char *out, int n)
{ 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 {
int http; /* 0=本地路径 1=http(s) */
char base[512]; /* 本地目录 或 http(s)://基址 */
char display[512]; /* 用于提示 */
} src_t;
/* ---------------- 索引包记录 ---------------- */
typedef struct {
char pkg[128];
char version[64];
long long version_code; /* 索引 Version-Code(正整数;has_=0 表示索引缺该项) */
int has_version_code;
char filename[256];
char sha256[128];
char depends[256];
char desc[256];
long long size;
int src_idx;
} pkgrec;
static src_t g_srcs[MAX_SRC];
static int g_nsrc = 0;
/* ---------------- 字符串工具 ---------------- */
static char *trim(char *s)
{
char *e;
while (*s==' '||*s=='\t') s++;
e = s + strlen(s) - 1;
while (e>=s && (*e=='\n'||*e=='\r'||*e==' '||*e=='\t')) *e-- = 0;
return s;
}
/* 去注释(# 到行尾) */
static void strip_comment(char *s)
{
char *p = strchr(s, '#');
if (p) *p = 0;
}
/* 包名合法:仅 [a-z0-9._-],且拒绝 "." / ".." / 前导或尾随 '.'。
* 只收小写是安全要求:SD 卡是 exFAT,名字比较不区分大小写,
* 允许大写就能用 "OS.MEOW.READER" 落到已装的 MeowAPPs/os.meow.reader 上
* 并绕过 is_system_pkg() 的签名门(现网包名全小写,收紧无兼容代价)。 */
static int valid_pkg(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 (!(islower(c) || isdigit(c) || c=='.' || c=='-' || c=='_')) return 0;
}
return 1;
}
static int unsafe_path(const char *s)
{
return s && strpbrk(s, " ;`$'\"&|<>") != NULL;
}
/* 前向声明:错误前缀(install/upgrade),定义见安装期安全设施一节。 */
static void op_err(const char *fmt, ...);
/* 前向声明:独立 '..' 路径组件判定,定义见导出过滤一节(安装期成员名检查也用)。 */
static int has_dotdot_comp(const char *p);
static int mkdir_p(const char *path)
{
char buf[BUF];
snprintf(buf, sizeof buf, "%s", path);
for (char *p = buf + 1; *p; p++) {
if (*p == '/') { *p = 0; mkdir(buf, 0755); *p = '/'; }
}
return mkdir(buf, 0755) == 0 || errno == EEXIST ? 0 : -1;
}
static void rm_rf(const char *path)
{
struct stat st;
if (lstat(path, &st) != 0) return;
if (S_ISDIR(st.st_mode)) {
DIR *d = opendir(path);
if (d) {
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name,".") || !strcmp(de->d_name,"..")) continue;
char p[BUF]; snprintf(p,sizeof p,"%s/%s",path,de->d_name);
rm_rf(p);
}
closedir(d);
}
rmdir(path);
} else {
remove(path);
}
}
static long long dir_size(const char *path)
{
struct stat st;
if (lstat(path, &st) != 0) return 0;
if (S_ISREG(st.st_mode)) return st.st_size;
if (!S_ISDIR(st.st_mode)) return 0;
DIR *d = opendir(path);
if (!d) return 0;
long long total = 0;
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name,".") || !strcmp(de->d_name,"..")) continue;
char p[BUF]; snprintf(p,sizeof p,"%s/%s",path,de->d_name);
long long s = dir_size(p);
if (s < 0) { closedir(d); return -1; }
total += s;
}
closedir(d);
return total;
}
static void human_size(long long b, char *out, int n)
{
if (b < 1024) snprintf(out,n,"%lld B", b);
else if (b < 1024*1024) snprintf(out,n,"%.1f KB", b/1024.0);
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;
int m = (int)mp + (mp < 10 ? 3 : -9); /* March-based → 1..12 */
if (m <= 2) y++;
long long rem = sec % 86400;
snprintf(out, n, "%04lld%02d%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 读取 ---------------- */
static int json_str(const char *j, const char *key, char *out, int n)
{
if (!j || !key) return -1;
char pat[160];
snprintf(pat,sizeof pat,"\"%s\"", key);
const char *p = j;
while ((p = strstr(p, pat)) != NULL) {
p += strlen(pat);
while (*p==' '||*p=='\t'||*p=='\n'||*p=='\r') p++;
if (*p != ':') { p++; continue; }
p++;
while (*p==' '||*p=='\t'||*p=='\n'||*p=='\r') p++;
if (*p != '"') { p++; continue; }
p++;
int o=0;
while (*p && *p != '"') {
if (*p=='\\' && p[1]) { if (o<n-1) out[o++]=p[1]; p+=2; continue; }
if (o<n-1) out[o++]=*p;
p++;
}
out[o]=0;
return 0;
}
return -1;
}
static int json_raw(const char *j, const char *key, char *out, int n)
{
if (!j || !key) return -1;
char pat[160];
snprintf(pat,sizeof pat,"\"%s\"", key);
const char *p = strstr(j, pat);
if (!p) return -1;
p += strlen(pat);
while (*p && *p != ':') p++;
if (*p != ':') return -1;
p++;
while (*p && (*p==' '||*p=='\t'||*p=='\n'||*p=='\r')) p++;
if (!*p) return -1;
char open=*p, close=0;
int depth=0, in_str=0, o=0;
if (open=='[') close=']'; else if (open=='{') close='}';
while (*p) {
if (in_str) {
if (o<n-1) out[o++]=*p;
if (*p=='\\' && p[1]) { if (o<n-1) out[o++]=p[1]; p+=2; continue; }
if (*p=='"') in_str=0;
p++; continue;
}
if (*p=='"') { in_str=1; if (o<n-1) out[o++]=*p; p++; continue; }
if (open=='['||open=='{') {
if (*p==open) depth++;
else if (*p==close) { depth--; if (o<n-1) out[o++]=*p; p++; if (depth==0) break; continue; }
if (o<n-1) out[o++]=*p;
p++; continue;
}
if (*p==','||*p=='\n'||*p=='\r'||*p=='}'||*p==']') break;
if (o<n-1) out[o++]=*p;
p++;
}
out[o]=0;
return out[0] ? 0 : -1;
}
/* 读 JSON 整型字段(键带引号、值必须为纯数字,如 "version_code": 12)。
* 缺键 / 值空 / 非数字 / 溢出 → 返回 -1;成功返回 0 并写入 *out。 */
static int json_int(const char *j, const char *key, long long *out)
{
if (!j || !key || !out) return -1;
char pat[160];
snprintf(pat, sizeof pat, "\"%s\"", key);
const char *p = j;
while ((p = strstr(p, pat)) != NULL) {
p += strlen(pat);
while (*p==' '||*p=='\t'||*p=='\n'||*p=='\r') p++;
if (*p != ':') { p++; continue; }
p++;
while (*p==' '||*p=='\t'||*p=='\n'||*p=='\r') p++;
if (!isdigit((unsigned char)*p)) return -1; /* 缺值 / 字符串 / 非数字 */
long long v = 0;
while (isdigit((unsigned char)*p)) {
int d = *p++ - '0';
if (v > (LLONG_MAX - d) / 10) return -1; /* 溢出 */
v = v * 10 + d;
}
*out = v;
return 0;
}
return -1;
}
typedef struct {
char name[256];
char version[64]; /* 仅作显示字符串(versionName 角色,不作比较) */
long long version_code; /* app.json "version_code"(正整数,安装/升级比较唯一依据;缺时 has_=0) */
int has_version_code; /* app.json 是否显式声明 version_code */
char exec[512];
char package[64]; /* app.json "package"(唯一权威包名;M35 后与文件名无关) */
char author[128];
char routes[512];
char services[512];
char depends[256];
char description[256];
char export_files[1024]; /* raw JSON 数组(导出白名单;无则黑名单模式) */
int has_routes, has_services, has_export_files;
} appmeta;
static int load_app_meta(const char *path, appmeta *m)
{
memset(m, 0, sizeof *m);
FILE *f = fopen(path, "r");
if (!f) return -1;
static char j[64*1024];
size_t n = fread(j, 1, sizeof j - 1, f);
fclose(f);
j[n] = 0;
int bal=0;
for (size_t i=0;i<n;i++){ if (j[i]=='{') bal++; else if (j[i]=='}') bal--; if (bal<0) return -1; }
if (bal != 0) return -1;
if (json_str(j,"name",m->name,sizeof m->name)!=0 || !m->name[0]) return -1;
if (json_str(j,"exec",m->exec,sizeof m->exec)!=0 || !m->exec[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;
m->has_version_code = (json_int(j,"version_code",&m->version_code)==0);
if (json_str(j,"author",m->author,sizeof m->author)!=0) m->author[0]=0;
if (json_str(j,"depends",m->depends,sizeof m->depends)!=0) m->depends[0]=0;
if (json_str(j,"description",m->description,sizeof m->description)!=0) m->description[0]=0;
m->has_routes = json_raw(j,"routes",m->routes,sizeof m->routes)==0;
m->has_services = json_raw(j,"services",m->services,sizeof m->services)==0;
m->has_export_files = json_raw(j,"export_files",m->export_files,sizeof m->export_files)==0;
return 0;
}
/* 从 .mpk 里尽力读 app.json 字段(package/name/version/depends/description)。
* 用 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)
{
memset(m, 0, sizeof *m);
char cmd[1024];
snprintf(cmd,sizeof cmd,
"gzip -dc '%s' 2>/dev/null | tar -t 2>/dev/null | grep 'app\\.json$'", file);
FILE *pf = popen(cmd, "r");
if (!pf) return -1;
char member[256]; member[0]=0;
if (fgets(member,sizeof member,pf)==NULL) { pclose(pf); return -1; }
char *mm = member; while (*mm==' '||*mm=='\t') mm++;
size_t mlen=strlen(mm); while (mlen&&(mm[mlen-1]=='\n'||mm[mlen-1]=='\r'||mm[mlen-1]==' ')) mm[--mlen]=0;
pclose(pf);
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);
pf = popen(cmd, "r");
if (!pf) return -1;
static char j[16384];
size_t r = fread(j,1,sizeof j-1,pf);
pclose(pf);
if (!r) return -1;
j[r]=0;
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;
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,"description",m->description,sizeof m->description)!=0) m->description[0]=0;
return 0;
}
/* SHA256(外部 sha256sum,busybox/宿主均有);失败返回 -1 */
static int sha256_file(const char *path, char *out, int n)
{
if (unsafe_path(path)) return -1;
char cmd[BUF];
snprintf(cmd,sizeof cmd,"sha256sum '%s' 2>/dev/null", path);
FILE *pf = popen(cmd,"r");
if (!pf) return -1;
char line[256];
if (fgets(line,sizeof line,pf)==NULL) { pclose(pf); return -1; }
pclose(pf);
char *h=line;
/* sha256sum 输出为 "<hash> <filename>",仅取第一个空白前 token */
for (char *q=h; *q; q++) { if (*q==' '||*q=='\t') { *q=0; break; } }
size_t L = strlen(h);
if (L != 64) return -1;
if (n>64) strcpy(out,h); else return -1;
return 0;
}
/* ---------------- 源加载 ---------------- */
static int curl_available(void)
{
if (access("/usr/data/mnt/sd_0/MeowOS/opt/bin/curl",X_OK)==0) return 1;
if (access("/usr/bin/curl",X_OK)==0) return 1;
return 0;
}
static const char *curl_bin(void)
{
if (access("/usr/data/mnt/sd_0/MeowOS/opt/bin/curl",X_OK)==0) return "/usr/data/mnt/sd_0/MeowOS/opt/bin/curl";
if (access("/usr/bin/curl",X_OK)==0) return "/usr/bin/curl";
return "curl";
}
/* 规范化一条源记录(去掉 file://、deb [..] 前缀),返回 0 */
static int normalize_src(const char *raw, src_t *s)
{
char line[600];
snprintf(line,sizeof line,"%s", raw);
strip_comment(line);
char *t = trim(line);
if (!*t) return -1;
/* deb [trusted=yes] <uri> */
if (!strncmp(t,"deb ", 4)) {
t = trim(t + 4);
/* 去掉 [ ... ] */
char *br = strchr(t,'[');
if (br) { char *b2 = strchr(br,']'); if (b2) memmove(br, b2+1, strlen(b2+1)+1); t = trim(t); }
} else if (!strncmp(t,"local ", 6)) {
t = trim(t + 6);
} else if (!strncmp(t,"http ", 5)) {
t = trim(t + 5);
}
if (!*t) return -1;
snprintf(s->base, sizeof s->base, "%s", t);
snprintf(s->display,sizeof s->display,"%s", t);
/* file:// 前缀 → 本地路径 */
if (!strncmp(s->base,"file://", 7)) {
memmove(s->base, s->base+7, strlen(s->base+7)+1);
s->http = 0;
} else if (!strncmp(s->base,"http://",7) || !strncmp(s->base,"https://",8)) {
s->http = 1;
} else {
s->http = 0;
}
return 0;
}
static int load_sources(void)
{
g_nsrc = 0;
char sp[BUF];
path_sources(sp,sizeof sp);
FILE *f = fopen(sp, "r");
if (f) {
char line[600];
while (fgets(line,sizeof line,f) && g_nsrc < MAX_SRC) {
src_t s;
if (normalize_src(line,&s)==0) g_srcs[g_nsrc++] = s;
}
fclose(f);
}
if (g_nsrc == 0) {
/* 缺省源:local MeowOS/opt/repo */
char d[BUF]; path_repo_default(d,sizeof d);
snprintf(g_srcs[0].base,sizeof g_srcs[0].base,"%s", d);
snprintf(g_srcs[0].display,sizeof g_srcs[0].display,"%s", d);
g_srcs[0].http = 0;
g_nsrc = 1;
printf("nyai: 无 sources.list,使用默认本地源 %s\n", d);
}
return g_nsrc;
}
/* 某源的索引文件路径:本地 = <base>/Packages;http = <base>/Packages(远端),本地缓存 = cache/src<i> */
static int source_index_local(const src_t *s, int idx, char *out, int n)
{
if (s->http) {
/* 缓存的远端索引 */
char c[BUF]; path_cache(c,sizeof c);
snprintf(out,n,"%s/src%d-Packages", c, idx);
return 0;
}
snprintf(out,n,"%s/Packages", s->base);
return 0;
}
static void source_index_url(const src_t *s, char *out, int n)
{
snprintf(out,n,"%s/Packages", s->base);
}
static void source_file_local(const src_t *s, const char *filename, char *out, int n)
{
snprintf(out,n,"%s/%s", s->base, filename);
}
static void source_file_url(const src_t *s, const char *filename, char *out, int n)
{
snprintf(out,n,"%s/%s", s->base, filename);
}
/* ---------------- 索引解析 ---------------- */
/* 从文件读 Packages 索引到 recs;返回条数 */
static int parse_index(const char *path, pkgrec *recs, int max)
{
FILE *f = fopen(path,"r");
if (!f) return 0;
int n = 0;
pkgrec cur; memset(&cur,0,sizeof cur); int have = 0;
char line[600];
while (fgets(line,sizeof line,f)) {
char *t = trim(line);
if (!*t) { /* 空行 => 终结一条 */
if (have && n < max) { recs[n++] = cur; }
memset(&cur,0,sizeof cur); have = 0;
continue;
}
char *colon = strchr(t, ':');
if (!colon) continue;
*colon = 0;
char *key = trim(t), *val = trim(colon+1);
if (!strcmp(key,"Package")) { snprintf(cur.pkg,sizeof cur.pkg,"%s",val); have = (cur.pkg[0]!=0); }
else if (!strcmp(key,"Version")) snprintf(cur.version,sizeof cur.version,"%s",val);
else if (!strcmp(key,"Version-Code")) { cur.version_code = atoll(val); cur.has_version_code = (val[0]!=0); }
else if (!strcmp(key,"Filename")) snprintf(cur.filename,sizeof cur.filename,"%s",val);
else if (!strcmp(key,"SHA256")) snprintf(cur.sha256,sizeof cur.sha256,"%s",val);
else if (!strcmp(key,"Size")) { cur.size = atoll(val); }
else if (!strcmp(key,"Depends")) snprintf(cur.depends,sizeof cur.depends,"%s",val);
else if (!strcmp(key,"Description")) snprintf(cur.desc,sizeof cur.desc,"%s",val);
}
if (have && n < max) recs[n++] = cur;
fclose(f);
return n;
}
/* 加载所有缓存索引(update 后);把 src_idx 标注好 */
static int load_all_indexes(pkgrec *recs, int max, const src_t *srcs, int nsrc)
{
int n=0;
for (int i=0;i<nsrc && n<max;i++) {
char p[BUF];
source_index_local(&srcs[i], i, p, sizeof p);
int k = parse_index(p, recs+n, max-n);
for (int j=0;j<k;j++) recs[n+j].src_idx = i;
n += k;
}
return n;
}
/* ---------------- 依赖解析/安装(递归) ---------------- */
static int pkg_installed(const char *pkg)
{
char p[BUF];
snprintf(p,sizeof p,"%s/MeowAPPs/%s/app.json", meow_root(), pkg);
return access(p, R_OK) == 0;
}
static int install_mpk_file(const char *mpk_path, const char *expect_pkg, int must_be_installed);
/* 按记录安装一个索引包(含 sha256 校验 + 依赖),done 记录已装避免循环 */
static int install_rec(pkgrec *r, pkgrec *recs, int nrec, char (*done)[128], int ndone, int depth);
/* 收集依赖并逐个安装(用于 install_by_name) */
static int install_deps_of(pkgrec *r, pkgrec *recs, int nrec, char (*done)[128], int *ndone, int depth)
{
if (depth > MAX_DEPTH) return -1;
if (!r->depends[0]) return 0;
char d[512]; snprintf(d,sizeof d,"%s", r->depends);
char *save=NULL, *tok = strtok_r(d, ", ", &save);
while (tok) {
tok = trim(tok);
if (*tok && valid_pkg(tok)) {
int seen=0;
for (int i=0;i<*ndone;i++) if (!strcmp(done[i],tok)) { seen=1; break; }
if (!seen && !pkg_installed(tok)) {
int idx=-1;
for (int i=0;i<nrec;i++) if (!strcmp(recs[i].pkg,tok)) { idx=i; break; }
if (idx<0) {
fprintf(stderr,"nyai: 警告: 依赖 %s 未在当前源找到\n", tok);
} else {
int rc = install_rec(&recs[idx], recs, nrec, done, *ndone, depth+1);
if (rc != 0) { fprintf(stderr,"nyai: 依赖 %s 安装失败\n", tok); return -1; }
}
}
}
tok = strtok_r(NULL, ", ", &save);
}
return 0;
}
/* 按名称从索引安装 */
static int install_by_name(const char *name, pkgrec *recs, int nrec)
{
int idx=-1;
for (int i=0;i<nrec;i++) if (!strcmp(recs[i].pkg,name)) { idx=i; break; }
if (idx<0) {
fprintf(stderr,"nyai: 未找到包 %s(先 nyai update 刷新索引)\n", name);
return 1;
}
char done[MAX_DEPS][128];
int ndone=0;
return install_rec(&recs[idx], recs, nrec, done, ndone, 0);
}
static int install_rec(pkgrec *r, pkgrec *recs, int nrec, char (*done)[128], int ndone, int depth)
{
/* 记录正在处理的包,防循环 */
if (ndone < MAX_DEPS) { snprintf(done[ndone],128,"%s", r->pkg); ndone++; }
/* 先装依赖 */
if (install_deps_of(r, recs, nrec, done, &ndone, depth+1) != 0) return 1;
/* 取本地路径(本地源用文件;http 源 curl 下载到 cache) */
src_t *s = &g_srcs[r->src_idx];
char local[BUF], tmp[BUF];
if (s->http) {
char c[BUF]; path_cache(c,sizeof c); mkdir_p(c);
char url[BUF]; source_file_url(s, r->filename, url, sizeof url);
snprintf(tmp,sizeof tmp,"%s/dl_%d", c, rand());
snprintf(local,sizeof local,"%s", tmp);
if (!curl_available()) {
fprintf(stderr,"nyai: http 源 %s 需要 curl(卡上 MeowOS/opt/bin/curl),当前不可用\n", s->display);
return 1;
}
char cmd[512];
snprintf(cmd,sizeof cmd,"%s -fsSL --connect-timeout 3 --max-time 8 '%s' -o '%s' 2>/dev/null", curl_bin(), url, tmp);
printf(" Fetch %s ...\n", url);
if (system(cmd)!=0) { fprintf(stderr,"nyai: 拉取 %s 失败(无网络?)\n", url); remove(tmp); return 1; }
} else {
source_file_local(s, r->filename, local, sizeof local);
if (access(local, R_OK)!=0) {
fprintf(stderr,"nyai: 源 %s 缺包文件 %s\n", s->display, local);
return 1;
}
}
/* SHA256 校验 */
if (r->sha256[0] && strlen(r->sha256)==64) {
char got[128];
if (sha256_file(local, got, sizeof got)!=0) {
fprintf(stderr,"nyai: 无法计算 %s 的 SHA256(需 sha256sum)\n", local);
if (s->http) remove(local);
return 1;
}
if (strcmp(got, r->sha256)!=0) {
fprintf(stderr,"nyai: SHA256 校验失败: %s\n got %s\n want %s\n", local, got, r->sha256);
if (s->http) { remove(local); }
return 1;
}
}
int rc = install_mpk_file(local, r->pkg, 0);
if (s->http) remove(local);
return rc;
}
/* ---------------- update ---------------- */
static int cmd_update(void)
{
load_sources();
printf("Reading package lists...\n");
char c[BUF]; path_cache(c,sizeof c); mkdir_p(c);
int ok = 0;
for (int i=0;i<g_nsrc;i++) {
src_t *s = &g_srcs[i];
printf("Get: [%d] %s", i+1, s->display);
char idxlocal[BUF];
source_index_local(s, i, idxlocal, sizeof idxlocal);
if (s->http) {
if (!curl_available()) { printf(" -- 无 curl,跳过 (http 源不可用)\n"); continue; }
char url[BUF]; source_index_url(s, url, sizeof url);
printf(" -> %s\n", url);
char cmd[512];
snprintf(cmd,sizeof cmd,"%s -fsSL --connect-timeout 3 --max-time 8 '%s' -o '%s' 2>/dev/null", curl_bin(), url, idxlocal);
if (system(cmd)!=0) {
printf(" nyai: 更新 %s 失败(无网络/源不可达),保留旧缓存\n", s->display);
continue; /* 保留旧缓存,不硬失败 */
}
ok++;
} else {
if (access(idxlocal, R_OK)==0) { printf(" ok\n"); ok++; }
else printf(" -- 无 Packages 索引(用 nyai index <dir> 生成)\n");
}
}
printf("Done.\n");
return ok ? 0 : 1;
}
/* ---------------- index <dir> ----------------
* 索引的 Package 字段 = 包内 app.json.package(**不看文件名**;文件名只作 Filename 定位)。
* 清单缺 package / 非法 → 跳过并告警,不入索引(否则 install 时验签与落盘目录会错)。
* 注意(MPK_SIGNATURE_DESIGN §5.5):索引记录的 SHA256 = 整包哈希(含尾部签名 footer 的 .mpk)。
* 必须先 `meow_sign sign <pkg>.mpk` 完成签名、再 `nyai index <dir>`,否则索引与装后的整包不一致,
* install 的 sha256_file 校验会失败。 */
static int cmd_index(const char *dir)
{
if (!dir || !*dir) { fprintf(stderr,"nyai: index: 缺目录\n"); return 2; }
char r[BUF]; snprintf(r,sizeof r,"%s", dir);
if (access(r, R_OK)!=0) { fprintf(stderr,"nyai: index: 目录 %s 不可读\n", r); return 1; }
DIR *d = opendir(r);
if (!d) { fprintf(stderr,"nyai: index: 无法打开 %s\n", r); return 1; }
char out[BUF]; snprintf(out,sizeof out,"%s/Packages", r);
FILE *f = fopen(out, "w");
if (!f) { fprintf(stderr,"nyai: index: 无法写 %s\n", out); closedir(d); return 1; }
printf("Generating index for %s ...\n", r);
struct dirent *de;
int n = 0;
while ((de = readdir(d))) {
if (de->d_name[0]=='.') continue;
size_t L = strlen(de->d_name);
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);
struct stat st; if (stat(fpath,&st)!=0) continue;
appmeta m;
char ver[64]="", dep[256]="", desc[256]="";
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(desc,sizeof desc,"%s", m.description[0]?m.description:m.name);
char sha[128]=""; sha256_file(fpath, sha, sizeof sha);
fprintf(f,"Package: %s\n", m.package);
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);
fprintf(f,"Filename: %s\n", de->d_name);
fprintf(f,"Size: %lld\n", (long long)st.st_size);
if (sha[0]) fprintf(f,"SHA256: %s\n", sha);
if (dep[0]) fprintf(f,"Depends: %s\n", dep);
if (desc[0]) fprintf(f,"Description: %s\n", desc);
fprintf(f,"\n");
printf(" %s (%s)\n", m.package, ver[0]?ver:"0.0.0");
n++;
}
fclose(f);
closedir(d);
printf("OK: %d package(s) indexed -> %s\n", n, out);
return 0;
}
/* ---------------- search ---------------- */
static int cmd_search(const char *pat, pkgrec *recs, int nrec)
{
int n=0;
printf("%-24s %-12s %s\n","Package","Version","Description");
printf("%-24s %-12s %s\n","-------","-------","-----------");
for (int i=0;i<nrec;i++) {
if (pat && *pat) {
if (!strstr(recs[i].pkg, pat) && !strstr(recs[i].desc, pat) && !strstr(recs[i].version, pat))
continue;
}
printf("%-24s %-12s %s\n", recs[i].pkg, recs[i].version[0]?recs[i].version:"?",
recs[i].desc[0]?recs[i].desc:"");
n++;
}
if (!n) printf("No match.\n");
else printf("\n%d package(s) found.\n", n);
return 0;
}
/* ---------------- list-upgradable ---------------- */
static int cmd_list_upgradable(pkgrec *recs, int nrec)
{
char r[BUF]; snprintf(r,sizeof r,"%s/MeowAPPs", meow_root());
DIR *d = opendir(r);
if (!d) { fprintf(stderr,"nyai: 无法打开 MeowAPPs\n"); return 1; }
int n=0;
printf("%-24s %-12s %-12s\n","Package","Installed","Available");
struct dirent *de;
while ((de = readdir(d))) {
if (de->d_name[0]=='.') continue;
if (!strcmp(de->d_name,"fonts")||!strcmp(de->d_name,"logs")||
!strcmp(de->d_name,"backgrounds")||!strcmp(de->d_name,"meow.conf")||
strstr(de->d_name,".bak_")) continue;
for (int i=0;i<nrec;i++) {
if (!strcmp(recs[i].pkg, de->d_name)) {
char ap[BUF]; snprintf(ap,sizeof ap,"%s/%s/app.json", r, de->d_name);
appmeta m;
if (load_app_meta(ap,&m)!=0) break;
/* 仅按 version_code 比较(APPS.md §3.8);任一侧缺 code → 不判定为可升级 */
if (recs[i].has_version_code && m.has_version_code &&
recs[i].version_code > m.version_code) {
printf("%-24s %-12s %-12s\n", de->d_name, m.version[0]?m.version:"?", recs[i].version);
n++;
}
break;
}
}
}
closedir(d);
if (!n) printf("All packages up to date.\n");
else printf("\n%d package(s) can be upgraded.\n", n);
return 0;
}
/* ---------------- II 期签名校验(MPK_SIGNATURE_DESIGN §5.1,shell-out meow_verify) ---------------- */
/* 系统包前缀:os.meow.*(防伪造系统包名)。
* **必须大小写不敏感**:安装落盘目录在 exFAT 上(名字比较不区分大小写),
* 若这里区分大小写,"OS.MEOW.READER" 会被当成第三方放行并落到已装的
* MeowAPPs/os.meow.reader 上,直接顶掉系统 App(valid_pkg 也已收紧为只收小写,
* 这里是第二道防线:任一改动失效都不会打开这个口子)。 */
static int is_system_pkg(const char *pkg)
{
return pkg && !strncasecmp(pkg, "os.meow.", 8);
}
/* meow_verify 二进制路径:环境变量 MEOW_VERIFY_BIN 覆盖(宿主测试用),缺省 /usr/bin/meow_verify。 */
static const char *verify_bin(void)
{
const char *v = getenv("MEOW_VERIFY_BIN");
return (v && *v) ? v : "/usr/bin/meow_verify";
}
/* 调用 meow_verify --system <mpk> --package <pkg> 验签。
* out_pkg 回传校验器输出的 footer.package(成功时)。返回校验器退出码:
* 0=有效签(三重绑定①通过) / 1=有签但不通过(改包名/负载/公钥) / 2=无签名 / 127=工具不可用。 */
static int verify_mpk_signature(const char *mpk, const char *pkg, char *out_pkg, int n)
{
if (!mpk || !pkg) return 127;
if (unsafe_path(mpk)) return 127;
char cmd[1200];
snprintf(cmd, sizeof cmd, "'%s' --system '%s' --package '%s' 2>/dev/null",
verify_bin(), mpk, pkg);
if (out_pkg && n > 0) out_pkg[0] = 0;
FILE *pf = popen(cmd, "r");
if (!pf) return 127;
char line[128];
if (fgets(line, sizeof line, pf) != NULL) {
char *e = line + strlen(line);
while (e > line && (e[-1]=='\n'||e[-1]=='\r'||e[-1]==' '||e[-1]=='\t')) *--e = 0;
if (out_pkg && n > 0) {
size_t cl = strlen(line);
if (cl >= (size_t)n) cl = (size_t)n - 1;
memcpy(out_pkg, line, cl); out_pkg[cl] = 0;
}
}
int rc = pclose(pf);
if (rc == -1) return 127;
if (WIFEXITED(rc)) return WEXITSTATUS(rc);
return 127;
}
/* 安装期保存「原签 .mpk」到 MeowData/<pkg>/data/<pkg>.mpk.src(导出保留签名用,§5.4)。
* 仅当本包带有效签名时调用。失败只告警(导不出签名但安装成功)。 */
static void save_mpk_source(const char *mpk, const char *pkg)
{
char sp[BUF], d[BUF], cmd[1100];
path_data(sp, sizeof sp, pkg, "data");
mkdir_p(sp);
snprintf(d, sizeof d, "%s/%s.mpk.src", sp, pkg);
snprintf(cmd, sizeof cmd, "cp '%s' '%s' 2>/dev/null", mpk, d);
if (system(cmd) != 0)
fprintf(stderr, "nyai: 警告: 保存原签包 %s.mpk.src 失败(导出将无签名)\n", pkg);
}
/* 导出保留签名:MeowData/<pkg>/data/<pkg>.mpk.src 存在 + 验签通过 + 其 app.json 版本 == 已装版本
* → 直接复制它作为导出结果(不二次签名);否则返回 -1(调用方现场重打包,无签名)。 */
static int export_preserve_sig(const char *pkg, const char *dst)
{
char sp[BUF], srcp[BUF], instj[BUF], cmd[1100];
path_data(sp, sizeof sp, pkg, "data");
snprintf(srcp, sizeof srcp, "%s/%s.mpk.src", sp, pkg);
if (access(srcp, R_OK) != 0) return -1;
char op[64];
int rc = verify_mpk_signature(srcp, pkg, op, sizeof op);
if (rc != 0) { fprintf(stderr, "nyai: export: 缓存原签包验签失败(rc=%d),回退重打包\n", rc); return -1; }
appmeta cached, inst;
if (mpk_appinfo(srcp, &cached) != 0) return -1;
snprintf(instj, sizeof instj, "%s/MeowAPPs/%s/app.json", meow_root(), pkg);
if (load_app_meta(instj, &inst) != 0) return -1;
if (strcmp(cached.version, inst.version) != 0) {
fprintf(stderr, "nyai: export: 缓存原签包版本(%s) != 已装(%s),回退重打包\n",
cached.version[0]?cached.version:"(?)", inst.version[0]?inst.version:"(?)");
return -1;
}
char tmp[520]; snprintf(tmp, sizeof tmp, "%s.tmp", dst);
snprintf(cmd, sizeof cmd, "cp '%s' '%s' 2>/dev/null", srcp, tmp);
if (system(cmd) != 0 || access(tmp, F_OK) != 0) return -1;
if (rename(tmp, dst) != 0) { rm_rf(tmp); return -1; }
return 0;
}
/* ---------------- 二进制依赖检查(app.json depends,新语义) ----------------
* app.json "depends" 现为二进制依赖声明:<name>[:<mod>][,...],<mod>=required|req(默认)/optional|opt。
* 与 launcher meow_parse_depends 同语义;按序查 /usr/bin/<name> 与 <root>/MeowOS/opt/bin/<name>(X_OK)。
* 安装时**不阻断**:缺 required 输出 WARN 标注「安装后启动将失败」;缺 optional 仅提示。 */
static int bin_dep_exists(const char *name)
{
char p[300];
snprintf(p, sizeof p, "/usr/bin/%s", name);
if (access(p, X_OK) == 0) return 1;
snprintf(p, sizeof p, "%s/MeowOS/opt/bin/%s", meow_root(), name);
if (access(p, X_OK) == 0) return 1;
return 0;
}
static void nyai_check_bin_depends(const appmeta *m)
{
if (!m || !m->depends[0]) return;
char d[256];
snprintf(d, sizeof d, "%s", m->depends);
char *save = NULL, *tok = strtok_r(d, ",", &save);
while (tok) {
tok = trim(tok);
if (!*tok) { tok = strtok_r(NULL, ",", &save); continue; }
char nm[64] = ""; int req = 1;
char *colon = strchr(tok, ':');
size_t nl = colon ? (size_t)(colon - tok) : strlen(tok);
if (nl >= sizeof nm) nl = sizeof nm - 1;
memcpy(nm, tok, nl); nm[nl] = 0;
if (colon) {
char *mod = trim(colon + 1);
if (!strcmp(mod, "optional") || !strcmp(mod, "opt")) req = 0;
else if (!strcmp(mod, "required") || !strcmp(mod, "req")) req = 1;
/* 未知修饰符按 required 处理 */
}
if (!*nm) { tok = strtok_r(NULL, ",", &save); continue; }
if (!bin_dep_exists(nm)) {
if (req)
printf("nyai: 警告: 依赖 %s 缺失(required),安装后启动将失败\n", nm);
else
printf("nyai: 提示: 可选依赖 %s 缺失\n", nm);
}
tok = strtok_r(NULL, ",", &save);
}
}
/* ---------------- 安装期安全设施:信号清理 / 子进程 / 成员检查 / footer 读取 ---------------- */
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, ...)
{
va_list ap;
va_start(ap, fmt);
fprintf(stderr, "nyai: %s: ", g_op);
vfprintf(stderr, fmt, ap);
va_end(ap);
}
/* 删掉本次的暂存目录(幂等;atexit / 信号 / 各失败分支共用)。 */
static void stg_cleanup(void)
{
if (!g_stg[0]) return;
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`:`<mode> <owner/group> <size> <date> <time> <name...>`。
* GNU tar 与 busybox tar 都是这 5 个固定字段,名字取余下整段(可含空格)。
* 返回 0 成功;-1 行格式不认识(size 非纯数字)。 */
static int tar_tv_parse(char *line, char *name, int n, long long *sz)
{
char *p = line;
char tok[5][64];
for (int i = 0; i < 5; i++) {
while (*p == ' ' || *p == '\t') p++;
if (!*p || *p == '\n') return -1;
int o = 0;
while (*p && *p != ' ' && *p != '\t' && *p != '\n') {
if (o < (int)sizeof tok[0] - 1) tok[i][o++] = *p;
p++;
}
tok[i][o] = 0;
}
for (char *q = tok[2]; *q; q++)
if (*q < '0' || *q > '9') return -1;
if (!tok[2][0]) return -1;
while (*p == ' ' || *p == '\t') p++;
if (!*p) return -1;
char *e = p + strlen(p);
while (e > p && (e[-1] == '\n' || e[-1] == '\r')) *--e = 0;
if ((int)strlen(p) + 1 > n) return -1;
snprintf(name, n, "%s", p);
*sz = atoll(tok[2]);
return 0;
}
/* 解包前逐条检查归档成员:类型只允许普通文件 '-' 与目录 'd'(拒符号链接/硬链接/
* 设备/FIFO);名字自己做净化,不依赖外部 tar 的路径处理——名称含 '..' 组件、
* 绝对路径、控制字符、或首字符 '-' 一律拒装。同时累加成员体积供解压炸弹门禁用。
* 返回 0 通过 / -1 读失败 / -2 成员类型非法 / -3 空归档 / -4 成员名非法 / -5 行格式不认识。 */
static int mpk_check_members(const char *mpk, long long *total_out)
{
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], name[1024];
int n = 0, rc = 0;
long long total = 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);
rc = -2;
break;
}
long long sz = 0;
if (tar_tv_parse(line, name, sizeof name, &sz) != 0) {
op_err("无法解析包成员列表(不支持的 tar 输出格式),拒绝安装\n");
rc = -5;
break;
}
if (name[0] == '/' || name[0] == '-' || has_dotdot_comp(name)) {
op_err("包成员路径非法(绝对路径/.. 穿越/首字符 '-'),拒绝安装: %s\n", name);
rc = -4;
break;
}
for (const char *q = name; *q; q++) {
unsigned char c = (unsigned char)*q;
if (c < 0x20 || c == 0x7f) {
op_err("包成员名含控制字符,拒绝安装\n");
rc = -4;
break;
}
}
if (rc) break;
if (total > LLONG_MAX - sz) { rc = -5; break; }
total += sz;
}
pclose(pf);
if (rc) return rc;
if (!n) return -3;
if (total_out) *total_out = total;
return 0;
}
/* 暂存所在文件系统余量检查(解包体积 + 16MB 余量)。statvfs 不可用时不阻断。 */
static int stg_space_ok(const char *base, long long need)
{
struct statvfs vfs;
if (statvfs(base, &vfs) != 0) return 0;
long long avail = (long long)vfs.f_bavail * (long long)vfs.f_frsize;
if (avail <= 0) return 0;
if (need + STG_SPACE_MARGIN > avail) {
op_err("SD 卡剩余空间不足:需要约 %lld MB + 16MB 余量,可用 %lld MB\n",
(need + 1048575) / 1048576, avail / 1048576);
return -1;
}
return 0;
}
/* 解包后复核暂存树:只允许普通文件与目录、普通文件 nlink 必须为 1
* (硬链接到外部文件会表现为 nlink>1);名字里不得有控制字符
* (tar 列名时可能把控制字符转义成可见文本,这里用 readdir 的真实 on-disk 名字复核)。 */
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;
for (const char *q = de->d_name; *q; q++) {
unsigned char c = (unsigned char)*q;
if (c < 0x20 || c == 0x7f) { ok = 0; break; }
}
if (!ok) break;
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;
}
/* MeowAPPs 下是否有与 pkg 仅大小写不同的已存在条目(exFAT 上两者是同一个 dentry)。
* 返回 1=有冲突(other 回传该条目名)/ 0=无 / -1=目录打不开(视为无)。 */
static int meowapps_name_conflict(const char *pkg, char *other, int n)
{
char dir[BUF];
if (snprintf(dir, sizeof dir, "%s/MeowAPPs", meow_root()) >= (int)sizeof dir) return -1;
DIR *d = opendir(dir);
if (!d) return -1;
int hit = 0;
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name, ".") || !strcmp(de->d_name, "..")) continue;
if (!strcmp(de->d_name, pkg)) continue; /* 逐字节相同 = 正常的重装 */
if (strcasecmp(de->d_name, pkg) == 0) {
if (other && n > 0) snprintf(other, n, "%s", de->d_name);
hit = 1;
break;
}
}
closedir(d);
return hit;
}
/* path 的父目录里是否存在**逐字节**等于 path 末段名字的条目(不依赖文件系统的大小写/
* 尾点/尾空格映射:用 readdir 读回真实 on-disk 名字再比)。1=存在 0=不存在。 */
static int path_exact_entry(const char *path)
{
const char *slash = strrchr(path, '/');
if (!slash || !slash[1]) return 0;
char dir[BUF];
size_t dl = (size_t)(slash - path);
if (dl == 0 || dl >= sizeof dir) return 0;
memcpy(dir, path, dl);
dir[dl] = 0;
const char *want = slash + 1;
DIR *d = opendir(dir);
if (!d) return 0;
int hit = 0;
struct dirent *de;
while ((de = readdir(d))) {
if (!strcmp(de->d_name, want)) { hit = 1; break; }
}
closedir(d);
return hit;
}
/* ---------------- 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");
/* ① 暂存目录:包名要解包后才知道,先用 .prep-<时间戳>-<pid>-<n> 原子占位 */
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);
/* ② 解包前检查归档成员(类型 + 名字净化)并统计解压后体积 */
long long unpack_bytes = 0;
int mr = mpk_check_members(mpk_path, &unpack_bytes);
stg_check_signal();
if (mr != 0) {
if (mr == -3) op_err("%s 解不出内容(不是 .mpk / 空归档)\n", mpk_path);
else if (mr == -1) op_err("无法读取包内容(%s)\n", mpk_path);
stg_cleanup();
return 1;
}
if (unpack_bytes > MAX_UNPACK_BYTES) {
op_err("包解压后体积约 %lld MB,超过 %lld MB 上限,拒绝安装(防解压炸弹)\n",
(unpack_bytes + 1048575) / 1048576, (long long)(MAX_UNPACK_BYTES / 1048576));
stg_cleanup();
return 1;
}
if (stg_space_ok(base, unpack_bytes) != 0) {
stg_cleanup();
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;
}
appmeta m;
if (load_app_meta(appj, &m) != 0) {
op_err("app.json 不合法(缺 name 或 exec)\n");
stg_cleanup();
return 1;
}
/* ④ 包名唯一来源: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);
int vr = verify_mpk_signature(mpk_path, pkg, NULL, 0);
stg_check_signal();
int system_pkg = is_system_pkg(pkg);
int keep_src = 0; /* 有效签 → 安装后另存原签 .mpk.src */
if (vr == 0) {
keep_src = 1;
printf("nyai: 签名校验通过 (package=%s)\n", pkg);
} else if (vr == 1) {
op_err("签名验证失败(内容被篡改/包名不符/公钥不符),拒绝安装\n");
stg_cleanup();
return 1;
} else if (vr == 2) {
if (system_pkg) {
op_err("系统包 %s 必须签名(无签名),拒绝安装\n", pkg);
stg_cleanup();
return 1;
}
printf("nyai: 提示: %s 未签名(第三方包放行安装)\n", pkg);
} else {
if (system_pkg) {
op_err("meow_verify 不可用(rc=%d),os.meow.* 拒装(宁缺勿装)\n", vr);
stg_cleanup();
return 1;
}
printf("nyai: 警告: meow_verify 不可用(rc=%d),%s 未验签放行\n", vr, pkg);
}
/* ⑦ 落地目录 + 防降级(基准 = MeowAPPs/<清单包名> 的 app.json) */
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;
}
/* exFAT 名字比较不区分大小写:若已装目录与 pkg 仅大小写不同,dst 会解析到那个
* 已装 App 上(顶掉它)。正常路径由 valid_pkg 只收小写挡住,这里是第二道。 */
{
char clash[128] = "";
int c = meowapps_name_conflict(pkg, clash, sizeof clash);
if (c == 1) {
op_err("安装目录名 %s 与已装 %s 仅大小写不同(SD 卡 exFAT 不区分大小写,会顶掉它),拒绝安装\n",
pkg, clash);
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) {
char instj[BUF];
appmeta im;
snprintf(instj, sizeof instj, "%s/app.json", dst);
if (load_app_meta(instj, &im) != 0 || !m.has_version_code || !im.has_version_code) {
op_err("版本号无法判定(新缺 version_code 或已装缺 version_code),拒绝安装\n");
stg_cleanup();
return 1;
}
if (m.version_code < im.version_code) {
op_err("不允许降级: 新版本 code %lld < 已装 code %lld\n", m.version_code, im.version_code);
stg_cleanup();
return 1;
}
rm_rf(bak);
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);
} else {
rm_rf(dst); /* 防御:全新安装前清残留 */
}
/* ⑧ 部署:暂存目录与 MeowAPPs 同在 SD → rename 原子 */
if (rename(stg, dst) != 0) {
op_err("部署 %s 失败: %s\n", dst, strerror(errno));
stg_cleanup();
if (replacing) rename(bak, dst); /* 回滚到旧版 */
return 1;
}
/* 部署后复核 on-disk 名字与清单包名逐字节相等(exFAT 的尾点/尾空格/大小写映射
* 若把目录名改成了别的,宁可回滚也不留一个身份不明的 App 目录)。 */
if (!path_exact_entry(dst)) {
op_err("部署后的目录名与清单包名 %s 不完全一致(文件系统名字映射差异),回滚\n", pkg);
rm_rf(dst);
if (replacing) rename(bak, dst);
stg_cleanup();
return 1;
}
g_stg[0] = 0; /* 已变成正式目录,不再属于清理范围 */
/* ⑨ 数据 init(MeowData 数据保留,仅保证目录存在) */
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);
printf("Setting up %s (%s) ...\n", pkg, m.version[0] ? m.version : "?");
/* ⑩ II 期:原签 .mpk 另存 MeowData/<pkg>/data/<pkg>.mpk.src(导出保留签名) */
if (keep_src) save_mpk_source(mpk_path, pkg);
/* ⑪ 安装成功后清理本次创建的 .bak_<pid>(失败回滚路径不动) */
if (replacing && bak[0]) { rm_rf(bak); printf("Removed backup %s\n", bak); }
printf("OK\n");
return 0;
}
static int cmd_install(const char *arg)
{
if (!arg || !*arg) { op_err("缺包文件路径或索引包名\n"); return 2; }
/* ① 存在的普通文件 → 直接装(文件名任意,身份来自包内 app.json) */
struct stat st;
size_t L = strlen(arg);
int looks_file = (strchr(arg, '/') != NULL) ||
(L >= 4 && !strcmp(arg + L - 4, ".mpk")) ||
(access(arg, F_OK) == 0);
if (stat(arg, &st) == 0 && S_ISREG(st.st_mode)) return install_mpk_file(arg, NULL, 0);
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];
int nrec = load_all_indexes(recs, MAX_PKG, g_srcs, g_nsrc);
if (nrec == 0) {
op_err("索引为空,先 nyai update 刷新(或直接 install <包文件>)\n");
return 1;
}
return install_by_name(arg, recs, nrec);
}
/* ---------------- list / info / remove / export / scan / upgrade ---------------- */
static int cmd_list(void)
{
char r[BUF]; snprintf(r,sizeof r,"%s/MeowAPPs", meow_root());
DIR *d = opendir(r);
if (!d) { fprintf(stderr,"nyai: list: 打开 %s 失败\n", r); return 1; }
int found=0;
printf("%-24s %-10s %-10s %s\n","Name","Version","Size","ID");
printf("%-24s %-10s %-10s %s\n","----","-------","----","--");
struct dirent *de;
while ((de=readdir(d))) {
if (de->d_name[0]=='.') continue;
if (!strcmp(de->d_name,"fonts")||!strcmp(de->d_name,"logs")||
!strcmp(de->d_name,"backgrounds")||!strcmp(de->d_name,"meow.conf")||
!strcmp(de->d_name,"meow") || strstr(de->d_name,".bak_")) continue;
char didx[BUF],dmeta[BUF];
snprintf(didx,sizeof didx,"%s/%s", r, de->d_name);
snprintf(dmeta,sizeof dmeta,"%s/app.json", didx);
appmeta m;
if (load_app_meta(dmeta,&m)!=0) continue;
long long sz=dir_size(didx);
char hs[24]; human_size(sz,hs,sizeof hs);
printf("%-24s %-10s %-10s %s\n", m.name[0]?m.name:de->d_name, m.version[0]?m.version:"?", hs, de->d_name);
found++;
}
closedir(d);
if (!found) printf("No packages found in MeowAPPs.\n");
else printf("\n%d package(s) installed.\n", found);
return 0;
}
/* 移除 MeowAPPs/<pkg>.bak_<pid> 类残留备份(install 升级/覆盖时产生) */
static void remove_pkg_backups(const char *pkg)
{
char r[BUF]; snprintf(r,sizeof r,"%s/MeowAPPs", meow_root());
DIR *d = opendir(r);
if (!d) return;
struct dirent *de;
/* 前缀 <pkg>.bak_ */
char pref[140]; snprintf(pref,sizeof pref,"%s.bak_", pkg);
size_t plen = strlen(pref);
while ((de = readdir(d))) {
if (strncmp(de->d_name, pref, plen)==0) {
char p[BUF]; snprintf(p,sizeof p,"%s/%s", r, de->d_name);
rm_rf(p);
}
}
closedir(d);
}
static int cmd_info(const char *pkg)
{
if (!pkg || !valid_pkg(pkg)) { fprintf(stderr,"nyai: info: 包名无效\n"); return 2; }
char dirp[BUF],appj[BUF];
snprintf(dirp,sizeof dirp,"%s/MeowAPPs/%s", meow_root(), pkg);
snprintf(appj,sizeof appj,"%s/app.json", dirp);
if (access(appj,R_OK)!=0) { fprintf(stderr,"nyai: info: 未安装 %s\n", pkg); return 1; }
appmeta m;
if (load_app_meta(appj,&m)!=0) { fprintf(stderr,"nyai: info: %s/app.json 不合法\n", pkg); return 1; }
long long sz=dir_size(dirp);
char hs[24]; human_size(sz,hs,sizeof hs);
printf("Package: %s\n", pkg);
printf("Name: %s\n", m.name);
printf("Version: %s\n", m.version[0]?m.version:"(none)");
if (m.has_version_code) printf("Version-Code: %lld\n", m.version_code);
else printf("Version-Code: (none)\n");
printf("Author: %s\n", m.author[0]?m.author:"(none)");
printf("Exec: %s\n", m.exec);
printf("Size: %s\n", hs);
printf("ExecFile:%s\n", access(m.exec,X_OK)==0?" yes":" no");
if (m.depends[0]) printf("Depends: %s\n", m.depends);
if (m.description[0]) printf("Desc: %s\n", m.description);
if (m.has_routes) printf("Routes: %s\n", m.routes);
if (m.has_services) printf("Services:%s\n", m.services);
return 0;
}
static int cmd_remove(const char *pkg, int purge)
{
if (!pkg || !valid_pkg(pkg)) { fprintf(stderr,"nyai: remove: 包名无效\n"); return 2; }
char dst[BUF]; snprintf(dst,sizeof dst,"%s/MeowAPPs/%s", meow_root(), pkg);
if (access(dst,F_OK)!=0) { fprintf(stderr,"nyai: remove: 未安装 %s\n", pkg); return 1; }
printf("Removing %s ...\n", pkg);
rm_rf(dst);
remove_pkg_backups(pkg); /* 清理 install 升级留下的 .bak_<pid> */
if (purge) {
char dd[BUF]; snprintf(dd,sizeof dd,"%s/MeowData/%s", meow_root(), pkg);
if (access(dd,F_OK)==0) { printf("Purging %s (data) ...\n", pkg); rm_rf(dd); }
}
printf("OK\n");
return 0;
}
/* ---- 导出过滤助手(APPS.md 附录 §3「导出过滤」) ---- */
/* 判断 p 是否含独立 '..' 路径组件 */
static int has_dotdot_comp(const char *p)
{
const char *s = p;
while (*s) {
if (s[0]=='.' && s[1]=='.' &&
(s==p || s[-1]=='/') && (s[2]==0 || s[2]=='/'))
return 1;
s++;
}
return 0;
}
/* 校验并相对化导出成员:去前导 '/' 与重复 './',禁绝对路径、禁 '..' 组件、禁 shell 危险字符 */
static int sanitize_member(const char *in, char *out, int n)
{
if (!in || !*in) return -1;
const char *p = in;
while (*p=='/' || (p[0]=='.'&&p[1]=='/')) p++;
if (*p=='/' || !*p) return -1; /* 去完仍是绝对/空 */
if (has_dotdot_comp(p)) return -1;
if (unsafe_path(p)) return -1;
if ((int)strlen(p)+1 > n) return -1;
snprintf(out,n,"%s", p);
return 0;
}
/* 把 app.json 的 exec 路径相对化到包目录下(导出前存在性检查 + 白名单打包用)。
* 约定(MEOW_APP_SDK 现有惯例,launcher 以绝对路径直接 execve):
* - exec 以本包目录 src/ 为前缀 → 取其后相对部分(同一 meow_root,如真机 src=.../MeowAPPs/os.meow.hello → hello);
* - exec 含 "/MeowAPPs/<pkg>/" 段 → 取该段后的相对路径(根路径与 src 不一致时仍可定位,如沙箱/CI 测试);
* - exec 为相对路径(无前导 /)→ 沿用 sanitize_member 语义(剥 ./、禁 ../、禁 shell 元字符);
* - 绝对路径但定位不到包目录段 → 非本包程序,返回 -1(拒绝导出)。
* 返回 0 成功,-1 非法。 */
static int exec_rel_to_pkg(const char *src, const char *exec, const char *pkg, char *out, int n)
{
if (!exec || !*exec) return -1;
const char *rel = NULL;
size_t sl = strlen(src);
if (strncmp(exec, src, sl) == 0 && exec[sl] == '/') {
rel = exec + sl + 1;
} else if (pkg && *pkg) {
char marker[272]; snprintf(marker, sizeof marker, "/MeowAPPs/%s/", pkg);
const char *m = strstr(exec, marker);
if (m) rel = m + strlen(marker);
}
if (rel) {
size_t rl = strlen(rel);
if (rl == 0 || rl >= (size_t)n) return -1;
if (has_dotdot_comp(rel) || unsafe_path(rel)) return -1;
memcpy(out, rel, rl); out[rl] = 0;
return 0;
}
if (exec[0] != '/') return sanitize_member(exec, out, n);
return -1;
}
/* 从 JSON 数组串(含 '[' ']')提取字符串成员;返回项数,负=格式错 */
static int json_array_strings(const char *arr, char items[][256], int max)
{
int n = 0;
const char *p = arr;
while (*p && *p!='[') p++;
if (*p!='[') return -1;
p++;
while (*p && n<max) {
while (*p==' '||*p=='\t'||*p=='\n'||*p=='\r'||*p==',') p++;
if (*p==']') break;
if (*p!='"') return -1; /* 数组元素必须是字符串 */
p++;
int o=0;
while (*p && *p!='"') {
if (*p=='\\' && p[1]) { if (o<255) items[n][o++]=p[1]; p+=2; continue; }
if (o<255) items[n][o++]=*p;
p++;
}
if (*p!='"') return -1;
p++;
items[n][o]=0;
n++;
}
return n;
}
/* 黑名单(默认):排除 *.pre_* *.bak_* logs/ *.log .DS_Store,其余全打 */
static int export_blacklist(const char *src, const char *outtmp)
{
char listf[BUF]; snprintf(listf,sizeof listf,"%s.wl", outtmp);
char cmd[2048];
snprintf(cmd,sizeof cmd,
"(cd '%s' && find . \\( -name '*.pre_*' -o -name '*.bak_*' -o -name '*.log' -o "
"-name '.DS_Store' -o -name logs \\) -prune -o -type f -print | sed 's|^\\./||') > '%s'", src, listf);
int rc = system(cmd);
if (rc!=0) { rm_rf(listf); return -1; }
snprintf(cmd,sizeof cmd,"tar -c -C '%s' -T '%s' 2>/dev/null | gzip -c > '%s' 2>/dev/null", src, listf, outtmp);
rc = system(cmd);
rm_rf(listf);
return rc==0 ? 0 : -1;
}
/* 白名单:只打 app.json + exec + export_files 声明的项(逐项安全校验) */
static int export_whitelist(const char *src, const char *exec_rel, const char *export_json, const char *outtmp)
{
char items[16][256];
int nit = json_array_strings(export_json, items, 16);
if (nit < 0) { fprintf(stderr,"nyai: export: export_files 格式非法(须为字符串数组)\n"); return -1; }
char listf[BUF]; snprintf(listf,sizeof listf,"%s.wl", outtmp);
FILE *lf = fopen(listf,"w");
if (!lf) return -1;
fprintf(lf,"app.json\n%s\n", exec_rel);
for (int i=0;i<nit;i++) {
char mem[BUF];
if (sanitize_member(items[i], mem, sizeof mem)!=0) {
fclose(lf); rm_rf(listf);
fprintf(stderr,"nyai: export: 白名单项含非法路径: %s\n", items[i]); return -1;
}
char p[BUF]; snprintf(p,sizeof p,"%s/%s", src, mem);
if (access(p,F_OK)!=0) {
fclose(lf); rm_rf(listf);
fprintf(stderr,"nyai: export: 白名单项不存在: %s\n", mem); return -1;
}
fprintf(lf,"%s\n", mem);
}
fclose(lf);
char cmd[2048];
snprintf(cmd,sizeof cmd,"tar -c -C '%s' -T '%s' 2>/dev/null | gzip -c > '%s' 2>/dev/null", src, listf, outtmp);
int rc = system(cmd);
rm_rf(listf);
return rc==0 ? 0 : -1;
}
static int cmd_export(const char *pkg, const char *out)
{
if (!pkg || !valid_pkg(pkg)) { fprintf(stderr,"nyai: export: 包名无效\n"); return 2; }
char src[BUF],dst[BUF];
snprintf(src,sizeof src,"%s/MeowAPPs/%s", meow_root(), pkg);
if (access(src,R_OK)!=0) { fprintf(stderr,"nyai: export: 未安装 %s\n", pkg); return 1; }
/* 导出前体检:app.json 存在且合法(有 name+exec) */
char appj[BUF]; snprintf(appj,sizeof appj,"%s/app.json", src);
appmeta m;
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 可为绝对(包目录下,相对化)或相对路径。 */
char exec_rel[BUF];
if (exec_rel_to_pkg(src, m.exec, pkg, exec_rel, sizeof exec_rel)!=0) {
fprintf(stderr,"nyai: export: exec 字段非法: %s\n", m.exec); return 1;
}
char exepath[BUF]; snprintf(exepath,sizeof exepath,"%s/%s", src, exec_rel);
if (access(exepath, X_OK)!=0) {
fprintf(stderr,"nyai: export: exec 文件 %s 不存在或不可执行,拒绝导出\n", exepath); return 1;
}
char outbuf[BUF];
if (out && *out && unsafe_path(out)) { fprintf(stderr,"nyai: export: 输出路径含非法字符\n"); return 1; }
snprintf(outbuf,sizeof outbuf,"%s", out && *out ? out : pkg);
if (!strstr(outbuf,".mpk")) strncat(outbuf,".mpk", sizeof outbuf-strlen(outbuf)-1);
snprintf(dst,sizeof dst,"%s", outbuf);
/* II 期:导出保留签名 —— 若安装时存了原签 .mpk.src 且其版本==已装 → 直接复制(不二次签名)。 */
if (export_preserve_sig(pkg, dst) == 0) {
printf("OK (signed, preserved from .mpk.src)\n");
return 0;
}
printf("Exporting %s -> %s ...\n", pkg, dst);
char tmpp[BUF]; snprintf(tmpp,sizeof tmpp,"%s.tmp", dst);
int rc;
if (m.has_export_files && m.export_files[0]) {
rc = export_whitelist(src, exec_rel, m.export_files, tmpp);
} else {
rc = export_blacklist(src, tmpp);
}
if (rc!=0 || access(tmpp,F_OK)!=0) { fprintf(stderr,"nyai: export: 打包失败\n"); rm_rf(tmpp); return 1; }
if (rename(tmpp,dst)!=0) { fprintf(stderr,"nyai: export: 写 %s 失败\n", dst); rm_rf(tmpp); return 1; }
printf("OK\n");
return 0;
}
static int cmd_scan(const char *dir)
{
char r[BUF]; snprintf(r,sizeof r,"%s", dir && *dir ? dir : ".");
DIR *d = opendir(r);
if (!d) { fprintf(stderr,"nyai: scan: 打开 %s 失败\n", r); return 1; }
printf("Scanning %s ...\n", r);
printf("%-24s %-10s %-10s %s\n","Package","Version","Size","File");
struct dirent *de; int found=0;
while ((de=readdir(d))) {
if (de->d_name[0]=='.') continue;
size_t L=strlen(de->d_name);
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);
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 ver[64]="";
appmeta m;
if (mpk_appinfo(f,&m)==0) snprintf(ver,sizeof ver,"%s",m.version);
printf("%-24s %-10s %-10s %s\n", m.package[0]?m.package:"?", ver[0]?ver:"?", hs, de->d_name);
found++;
}
closedir(d);
if (!found) printf("No .mpk packages found.\n");
else printf("\n%d package(s) available.\n", found);
return 0;
}
/* upgrade <pkg> [file]:目标包名 pkg 必须已安装;包文件内部清单的 package 必须等于 pkg。
* 其余(暂存/验签/防降级/部署/清理)全部走 install_mpk_file 同一实现,不再单独维护一套。 */
static int cmd_upgrade(const char *pkg, const char *file)
{
if (!pkg || !valid_pkg(pkg)) { op_err("包名无效\n"); return 2; }
char dst[BUF], mpk[BUF];
if (snprintf(dst, sizeof dst, "%s/MeowAPPs/%s", meow_root(), pkg) >= (int)sizeof dst) {
op_err("包名过长\n"); return 1;
}
if (access(dst, F_OK) != 0) { op_err("%s 未安装(如需全新安装请用 install)\n", pkg); return 1; }
if (file && *file) snprintf(mpk, sizeof mpk, "%s", file);
else snprintf(mpk, sizeof mpk, "%s.mpk", pkg); /* 默认路径约定,不是身份来源 */
if (access(mpk, R_OK) != 0) { op_err("找不到包文件 %s\n", mpk); return 1; }
return install_mpk_file(mpk, pkg, 1);
}
/* ---------------- usage / main ---------------- */
static void usage(FILE *o, const char *argv0)
{
fprintf(o,
"nyai %s — MeowOS package manager (apt/yum style)\n"
"\n"
"Usage:\n"
" %s update Read all sources into local index cache\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 list List installed apps (MeowAPPs/*)\n"
" %s info <pkg> Show package details (app.json)\n"
" %s search [pattern] Search index\n"
" %s list-upgradable Show packages with newer index version\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 scan [dir] List .mpk in dir (default .)\n"
" %s upgrade <pkg> [file] Upgrade installed <pkg>, keep MeowData\n"
" %s --help This help\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"
"Exit codes: 0 success, non-zero error.\n",
NYAI_VERSION, argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,argv0,
meow_root(), meow_root());
}
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; }
const char *cmd = argv[1];
if (!strcmp(cmd,"--help")||!strcmp(cmd,"-h")) { usage(stdout, argv[0]); return 0; }
if (!strcmp(cmd,"update")) { return cmd_update(); }
if (!strcmp(cmd,"install")) {
if (argc<3) { op_err("缺包文件路径或索引包名\n"); return 2; }
load_sources();
return cmd_install(argv[2]);
}
if (!strcmp(cmd,"remove")) {
if (argc<3) { fprintf(stderr,"nyai: remove: 缺包名\n"); return 2; }
int purge=0; for (int i=3;i<argc;i++) if (!strcmp(argv[i],"--purge")) purge=1;
return cmd_remove(argv[2], purge);
}
if (!strcmp(cmd,"list")) return cmd_list();
if (!strcmp(cmd,"info")) {
if (argc<3) { fprintf(stderr,"nyai: info: 缺包名\n"); return 2; }
return cmd_info(argv[2]);
}
if (!strcmp(cmd,"search")) { load_sources(); pkgrec recs[MAX_PKG]; int n=load_all_indexes(recs,MAX_PKG,g_srcs,g_nsrc); return cmd_search(argc>2?argv[2]:NULL, recs, n); }
if (!strcmp(cmd,"list-upgradable")) { load_sources(); pkgrec recs[MAX_PKG]; int n=load_all_indexes(recs,MAX_PKG,g_srcs,g_nsrc); return cmd_list_upgradable(recs,n); }
if (!strcmp(cmd,"index")) {
if (argc<3) { fprintf(stderr,"nyai: index: 缺目录\n"); return 2; }
return cmd_index(argv[2]);
}
if (!strcmp(cmd,"export")) {
if (argc<3) { fprintf(stderr,"nyai: export: 缺包名\n"); return 2; }
return cmd_export(argv[2], argc>3?argv[3]:NULL);
}
if (!strcmp(cmd,"scan")) return cmd_scan(argc>2?argv[2]:NULL);
if (!strcmp(cmd,"upgrade")) {
g_op = "upgrade";
if (argc<3) { op_err("缺包名\n"); return 2; }
return cmd_upgrade(argv[2], argc>3?argv[3]:NULL);
}
fprintf(stderr,"nyai: 未知命令 '%s'(--help 查看用法)\n", cmd);
return 2;
}