Files
dsh-purge2/lib/extract-asar.js
叫我小杨同学 335ddee90f fix: 全仓审计加固与死代码清理,版本 1.1.87
- 回滚/备份:空 bak 改名 .bad 跳过;升级刷新 bak 补上单数 marker 补丁;统一原子写
- 补丁命中:optional 命中不符记软失败;#42 插入型替换加探针防重复插入
- Windows:进程枚举改用 | 分隔 + UTF-8,修复中文路径换包不杀旧进程;换包失败拉起原版
- 安全:asar 解包防越界;红队路径校验 realpath 防空 symlink;更新仅 https;报告回放去换行注入
- 提示词钩子:停用后 rewriteFn 返回原装配文本并清理 assemble guard;缓存签名纳入红队/策略/override
- 红队:listEngagements 默认不算统计;listVulns/报告支持 evidenceLimit
- 子代理名额占位即时生效并回退;--apply 有失败项退出码 1
- 清理:删除 lib/mnemon-settings.js 及 13 处死代码/未使用 import;移除 test 目录与 test 脚本
2026-11-01 21:47:03 +08:00

480 lines
16 KiB
JavaScript
Raw Permalink 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.
import nodeFs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
let cachedDiskFs;
/**
* 真实磁盘上的 fs。
* Electron 自带的 fs 会把 `resources/app` 映射进仍在的 `app.asar`,
* 往里面新建文件会 ENOENT。解开目录,以及归档还在时打补丁,走这里。
* 非 Electron 环境没有 original-fs,退回 node:fs。
*/
export function diskFs() {
if (cachedDiskFs) return cachedDiskFs;
process.noDeprecation = true;
try {
cachedDiskFs = createRequire(import.meta.url)("original-fs");
} catch {
cachedDiskFs = nodeFs;
}
return cachedDiskFs;
}
/**
* asar 判定缓存(启动性能修复 / F14 + F17)。
*
* `asarShadowsPath()` 每次调用都要向上探最多 12 层,每层 1~2 次 original-fs 系统调用,
* 于是**每一次**经 hostFs 的调用都变成约 25 次系统调用。实测:
* - findAiBaseWeb 的兜底目录扫描 6 万个目录:裸 node:fs 6.2 s → 经这层 94 s;
* - 兜底扫描修掉之后,剩下的模块加载期里,光是这套探针自身仍占宿主启动 CPU 的 24%
* (4117 ms / 17226 ms)。
*
* 缓存分两级:
* 1) 持久级:5 s 存活期。任何一次经 hostFs 的**写操作**立即清空;插件自己搬 app.asar 的
* 那些函数(renameAsarAside / extractAsarToSide / commitExtractedDir / …)也显式清空。
* 进程外的官方更新器换 app.asar 由 5 s 存活期兜底,官方更新后本来也会重启宿主。
* 2) 作用域级:withFsProbeCache() 显式开合的强缓存。作用域内不改文件系统,
* 给目录递归扫描那种十万次调用的一次性突发用。
*/
const PROBE_TTL_MS = 5000;
const PROBE_MAX_ENTRIES = 400000;
let probePersist = null;
let probePersistEpoch = 0;
let probeScope = null;
/** 丢弃全部 asar 判定缓存。任何可能改动 app.asar 的操作之后都必须调用。 */
export function invalidateAsarProbes() {
probePersist = new Map();
probePersistEpoch = Date.now();
}
function activeProbes() {
if (probeScope) return probeScope;
const now = Date.now();
if (!probePersist || now - probePersistEpoch >= PROBE_TTL_MS) invalidateAsarProbes();
return probePersist;
}
/** 在给定的同步函数内启用强缓存(作用域内不得改动文件系统)。可重入。 */
export function withFsProbeCache(fn) {
if (probeScope) return fn();
probeScope = new Map();
try {
return fn();
} finally {
probeScope = null;
}
}
function probeKey(kind, p) {
return kind + String(p).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
}
function probeAsarFileIn(dir) {
try {
const asar = path.join(dir, "app.asar");
return diskFs().existsSync(asar) && diskFs().statSync(asar).isFile();
} catch {
return false;
}
}
function asarFileIn(dir) {
if (!dir) return false;
const cache = activeProbes();
const k = probeKey("f:", dir);
const hit = cache.get(k);
if (hit !== undefined) return hit;
const v = probeAsarFileIn(dir);
if (cache.size < PROBE_MAX_ENTRIES) cache.set(k, v);
return v;
}
function computeAsarShadows(raw) {
let dir = path.normalize(raw);
for (let i = 0; i < 12; i += 1) {
const base = path.basename(dir).toLowerCase();
if ((base === "app" || base.endsWith(".asar")) && asarFileIn(path.dirname(dir))) return true;
if (asarFileIn(dir)) return true;
const parent = path.dirname(dir);
if (!parent || parent === dir) break;
dir = parent;
}
return false;
}
/** 这个路径会被 Electron 映射进旁边的 app.asar。已解开且归档已挪走时为假。 */
export function asarShadowsPath(targetPath) {
const raw = String(targetPath || "").trim();
if (!raw) return false;
const cache = activeProbes();
const k = probeKey("s:", raw);
const hit = cache.get(k);
if (hit !== undefined) return hit;
const v = computeAsarShadows(raw);
if (cache.size < PROBE_MAX_ENTRIES) cache.set(k, v);
return v;
}
/**
* 宿主文件用哪套 fs。
* 没有 app.asar(Web、第三方桌面、官方包已经解开并挪走归档)时继续用 node:fs。
* 归档还在、Electron 会把 resources/app 映进去时,改用真实磁盘。
*/
export function fsFor(targetPath) {
return asarShadowsPath(targetPath) ? diskFs() : nodeFs;
}
function fsForPair(left, right) {
return asarShadowsPath(left) || asarShadowsPath(right) ? diskFs() : nodeFs;
}
// 这些方法会改动文件系统,可能新增/删除 app.asar,调用前必须让探针缓存失效。
// openSync 故意不列入:它多数时候是只读打开,放进来会把缓存冲掉。
const HOST_WRITE = new Set([
"writeFileSync", "mkdirSync", "chmodSync", "unlinkSync", "rmSync",
"renameSync", "appendFileSync", "symlinkSync", "copyFileSync",
]);
function route(method, pair) {
const isWrite = HOST_WRITE.has(method);
return (first, ...rest) => {
if (isWrite) invalidateAsarProbes();
const f = pair ? fsForPair(first, rest[0]) : fsFor(first);
return f[method](first, ...rest);
};
}
const HOST_SYNC = [
"existsSync", "statSync", "lstatSync", "readFileSync", "writeFileSync",
"readdirSync", "mkdirSync", "realpathSync", "chmodSync", "unlinkSync",
"rmSync", "renameSync", "appendFileSync", "openSync", "symlinkSync",
"accessSync", "truncateSync", "utimesSync",
];
// 这些吃的是 fd,不是路径。走 route() 会把数字 fd 当路径做 asar 探测(#150)。
const HOST_FD = [
"closeSync", "readSync", "writeSync", "fsyncSync", "fstatSync", "futimesSync",
];
/** 按路径自动选择 node:fs 或真实磁盘。补丁、探测、状态都走这一个。 */
export const hostFs = {
copyFileSync: route("copyFileSync", true),
promises: {
readFile: (p, ...args) => fsFor(p).promises.readFile(p, ...args),
writeFile: (p, ...args) => { invalidateAsarProbes(); return fsFor(p).promises.writeFile(p, ...args); },
mkdir: (p, ...args) => { invalidateAsarProbes(); return fsFor(p).promises.mkdir(p, ...args); },
unlink: (p, ...args) => { invalidateAsarProbes(); return fsFor(p).promises.unlink(p, ...args); },
rm: (p, ...args) => { invalidateAsarProbes(); return fsFor(p).promises.rm(p, ...args); },
copyFile: (src, dest, ...args) => { invalidateAsarProbes(); return fsForPair(src, dest).promises.copyFile(src, dest, ...args); },
rename: (src, dest, ...args) => { invalidateAsarProbes(); return fsForPair(src, dest).promises.rename(src, dest, ...args); },
},
};
for (const method of HOST_SYNC) hostFs[method] = route(method, false);
for (const method of HOST_FD) hostFs[method] = (fd, ...args) => nodeFs[method](fd, ...args);
/** Electron 会把 app.asar 伪装成目录。读归档必须走 original-fs,否则 isFile 为假、解不开。 */
function archiveFs() {
return diskFs();
}
function renameBusy(error) {
const code = error && error.code;
return code === "EBUSY" || code === "EPERM" || code === "EACCES";
}
export function renameAsarAside(asarPath) {
// 这一步直接改动 app.asar 的有无,必须让 asar 判定缓存立刻失效。
invalidateAsarProbes();
const afs = archiveFs();
if (!asarPath || !afs.existsSync(asarPath)) return { renamed: false };
const bak = `${asarPath}.bak`;
try {
if (afs.existsSync(bak)) afs.rmSync(bak, { force: true });
afs.renameSync(asarPath, bak);
return { renamed: true, bak };
} catch (error) {
if (renameBusy(error)) return { renamed: false, busy: true, bak };
throw error;
}
}
export function asarArchiveIsFile(p) {
try {
const afs = archiveFs();
return Boolean(p) && afs.existsSync(p) && afs.statSync(p).isFile();
} catch {
return false;
}
}
/** 用大小和修改时间认出是不是同一份归档。 */
export function asarFileIdentity(asarPath) {
try {
const st = archiveFs().statSync(asarPath);
if (!st.isFile()) return null;
return { size: st.size, mtimeMs: Math.round(st.mtimeMs) };
} catch {
return null;
}
}
export function sameAsarIdentity(left, right) {
return Boolean(
left && right
&& left.size === right.size
&& left.mtimeMs === right.mtimeMs,
);
}
/**
* 可用目录配同一份归档:不解包。
* 目录不在,或记录过的归档大小/时间变了:解一次。
* 升级标记碰上另一份归档,同样解一次。目录还在、又没记录过、也不是升级:先认这份归档,不解。
*/
export function shouldExtractAsar({ usable, identity, recorded, upgrade }) {
if (!identity) return false;
if (!usable) return true;
const same = sameAsarIdentity(identity, recorded);
if (upgrade && !same) return true;
if (recorded && !same) return true;
return false;
}
function readHeader(fd, afs) {
const pre = Buffer.alloc(16);
if (afs.readSync(fd, pre, 0, 16, 0) !== 16) throw new Error("asar header truncated");
if (pre.readUInt32LE(0) !== 4) throw new Error("not an asar archive");
const headerSize = pre.readUInt32LE(4);
const jsonSize = pre.readUInt32LE(12);
if (!headerSize || !jsonSize || jsonSize > 256 * 1024 * 1024) throw new Error("asar header size invalid");
const jsonBuf = Buffer.alloc(jsonSize);
if (afs.readSync(fd, jsonBuf, 0, jsonSize, 16) !== jsonSize) throw new Error("asar header short");
return {
header: JSON.parse(jsonBuf.toString("utf8")),
dataOffset: 8 + headerSize,
};
}
function walk(node, prefix, out) {
const files = node && node.files;
if (!files) return;
for (const [name, info] of Object.entries(files)) {
const rel = prefix ? `${prefix}/${name}` : name;
if (info && info.files) walk(info, rel, out);
else if (info) out.push({ rel, info });
}
}
function copyRange(afs, fd, offset, size, outPath) {
const outFs = diskFs();
const out = outFs.openSync(outPath, "w");
try {
const buf = Buffer.alloc(Math.min(1024 * 1024, Math.max(size, 1)));
let left = size;
let pos = offset;
while (left > 0) {
const n = afs.readSync(fd, buf, 0, Math.min(buf.length, left), pos);
if (n <= 0) throw new Error(`asar short read at ${outPath}`);
outFs.writeSync(out, buf, 0, n);
pos += n;
left -= n;
}
} finally {
outFs.closeSync(out);
}
}
/** asar 里标记 unpacked 的原生文件不在归档正文。解开目录启动前必须补到 resources/app。 */
export function overlayAsarUnpacked(asarPath, destDir) {
const outFs = diskFs();
const unpacked = `${asarPath}.unpacked`;
if (!destDir || !outFs.existsSync(unpacked)) return { copied: 0, missing: true };
let copied = 0;
const walk = (dir) => {
for (const ent of outFs.readdirSync(dir, { withFileTypes: true })) {
const from = path.join(dir, ent.name);
const to = path.join(destDir, path.relative(unpacked, from));
if (ent.isDirectory()) {
outFs.mkdirSync(to, { recursive: true });
walk(from);
} else if (ent.isFile()) {
if (outFs.existsSync(to)) continue;
outFs.mkdirSync(path.dirname(to), { recursive: true });
outFs.copyFileSync(from, to);
copied += 1;
}
}
};
walk(unpacked);
return { copied };
}
function discardDir(dir) {
if (!dir) return;
try { diskFs().rmSync(dir, { recursive: true, force: true }); } catch { /* 残留目录下次再清 */ }
}
/** 解到目标旁边的目录。失败时删掉半成品,不碰已经在用的那一份。 */
export function extractAsarToSide(asarPath, destDir) {
const next = `${destDir}.dshpurge-next`;
discardDir(next);
try {
const count = extractAsar(asarPath, next);
return { next, count };
} catch (error) {
discardDir(next);
throw error;
}
}
function restoreFromPrev(fs, destDir, prev) {
if (!prev || fs.existsSync(destDir)) return false;
try {
fs.renameSync(prev, destDir);
return true;
} catch {
return false;
}
}
/** 解开完成之后才把旧目录挪走、新目录换上。换不上就把旧目录放回去,仍然失败就写 recover.json 保留 nextDir 给下次重试。 */
export function commitExtractedDir(destDir, nextDir) {
const fs = diskFs();
if (!nextDir || !fs.existsSync(nextDir)) throw new Error("解包目录不存在");
let prev = "";
if (fs.existsSync(destDir)) {
prev = `${destDir}.dshpurge-prev`;
if (fs.existsSync(prev)) prev = `${destDir}.dshpurge-prev-${Date.now()}`;
fs.renameSync(destDir, prev);
}
try {
fs.renameSync(nextDir, destDir);
} catch (error) {
const restored = restoreFromPrev(fs, destDir, prev);
const recoverFp = `${destDir}.dshpurge-recover.json`;
try {
fs.writeFileSync(recoverFp, JSON.stringify({
when: new Date().toISOString(),
destDir,
nextDir,
prev,
prevRestored: restored,
error: String(error?.message || error),
}, null, 2));
} catch { /* 救援信息 best effort */ }
const err = new Error(
`commitExtractedDir failed; nextDir kept at ${nextDir}, prev at ${prev}, recover=${recoverFp}: ${error && error.message || error}`
);
err.cause = error;
err.recover = recoverFp;
throw err;
}
return { prev };
}
/** 升级脚本事先挪走的旧目录。补丁没写完时用它放回去。 */
export function newestPreservedDir(destDir) {
const fs = diskFs();
const parent = path.dirname(destDir);
const prefix = `${path.basename(destDir)}.dshpurge-prev`;
let names = [];
try { names = fs.readdirSync(parent); } catch { return ""; }
let best = "";
let bestTime = -1;
for (const name of names) {
if (name !== prefix && !name.startsWith(`${prefix}-`)) continue;
const full = path.join(parent, name);
let time = 0;
try { time = fs.statSync(full).mtimeMs; } catch { continue; }
if (time >= bestTime) {
best = full;
bestTime = time;
}
}
return best;
}
/** 补丁没写完时,把刚才挪走的旧目录放回原处。 */
export function restoreExtractedDir(destDir, prev) {
const fs = diskFs();
if (!prev || !fs.existsSync(prev)) return false;
let failed = "";
if (fs.existsSync(destDir)) {
failed = `${destDir}.dshpurge-failed-${Date.now()}`;
fs.renameSync(destDir, failed);
}
try {
fs.renameSync(prev, destDir);
} catch (error) {
if (failed && !fs.existsSync(destDir)) {
try { fs.renameSync(failed, destDir); } catch { /* 旧目录仍在 prev */ }
}
throw error;
}
if (failed) {
try { fs.rmSync(failed, { recursive: true, force: true }); } catch { /* 坏目录留着不影响已放回的那一份 */ }
}
return true;
}
export function extractAsar(asarPath, destDir) {
const afs = archiveFs();
const fd = afs.openSync(asarPath, "r");
let count = 0;
let skippedOutside = 0;
try {
const { header, dataOffset } = readHeader(fd, afs);
const entries = [];
walk(header, "", entries);
const outFs = diskFs();
outFs.mkdirSync(destDir, { recursive: true });
const destResolved = path.resolve(destDir);
for (const entry of entries) {
const info = entry.info;
// 归档头里的 key 不受信:带 ".." 的路径会写到 destDir 之外。symlink 分支下面已防,
// 普通文件同样要防,否则换包链路就是一条任意写。
const outPath = path.resolve(destDir, ...entry.rel.split("/"));
if (outPath !== destResolved && !outPath.startsWith(destResolved + path.sep)) {
skippedOutside += 1;
continue;
}
outFs.mkdirSync(path.dirname(outPath), { recursive: true });
if (info.unpacked) continue;
if (typeof info.link === "string") {
// symlink 目标解析后不能逃离 destDir(防越界,asar 来源不受信时)
const linkResolved = path.resolve(path.dirname(outPath), info.link);
const inside = linkResolved === destResolved
|| linkResolved.startsWith(destResolved + path.sep);
if (!inside) {
// 目标在 destDir 外,跳过(主程序原文件仍在归档里)
continue;
}
try {
outFs.symlinkSync(info.link, outPath);
} catch {
// 不能建链接就跳过,主程序文件仍在归档里
}
continue;
}
const size = Number(info.size || 0);
const offset = dataOffset + Number(info.offset || 0);
if (size === 0) {
outFs.writeFileSync(outPath, Buffer.alloc(0));
} else {
copyRange(afs, fd, offset, size, outPath);
}
count += 1;
}
} finally {
afs.closeSync(fd);
}
if (skippedOutside > 0) {
try { console.warn(`[dsh-purge] extractAsar: skipped ${skippedOutside} entr${skippedOutside === 1 ? "y" : "ies"} outside ${destDir}`); } catch {}
}
return count;
}