打包脚本先校验后落盘

vite-export-xhs-minitool/scripts/pack.mjs:抽出 encodeZip,makeZip 先把条目与字节算完并通过 index.html / 禁止条目 / 体积校验,最后才写 zip,失败时不再留半成品。
scripts/.pack.test.mjs:补测试覆盖 makeZip 内部校验失败时不留 zip。
resources/agc-skills/manifest.json:同步该 Skill 的内容指纹与 pack 版本。
This commit is contained in:
2026-10-06 21:39:21 +08:00
parent 6487b52b31
commit 230c9a2d28
3 changed files with 50 additions and 11 deletions
@@ -1,6 +1,6 @@
{
"schemaVersion": "agc-skill-pack.v1",
"version": "2026-08-26.66",
"version": "2026-08-26.67",
"skills": [
{
"name": "agc-unity-editor",
@@ -204,7 +204,7 @@
"scripts/validate.mjs",
"scripts/vite.config.xhs-minitool.mjs"
],
"sha256": "8a096ba5ac398ee45276e47d955691f834ecb0af8facadb43d52186f90458313"
"sha256": "ad9b5a6dc1b71e0413a1a71780c60d17076d13fbf67517a939c57565e6d0a477"
}
]
}
@@ -69,6 +69,31 @@ test('存在不支持文件时打包整体失败,且不删除任何文件', ()
assert.equal(existsSync(join(BASE, 'no-delete-out')), false, '失败时不应写出 zip');
});
test('makeZip 的校验也发生在落盘前,失败时不留 zip', () => {
// 缺 index.html:文件扩展名都合法,预检会放行,失败点在 makeZip。
const noIndex = writeBuiltDir('no-index');
rmSync(join(noIndex, 'index.html'));
const noIndexOut = join(BASE, 'no-index-out', 'xhs-minitool.zip');
assert.throws(
() => packMinitool({ cwd: BASE, viteBuiltDir: 'no-index', zipOut: noIndexOut }),
/缺少 index\.html/,
);
assert.equal(existsSync(noIndexOut), false, '缺 index.html 时不应写出 zip');
// 含禁止条目:vite.config.js 扩展名在白名单里,但命中 FORBIDDEN_ENTRY。
writeBuiltDir('forbidden-entry', { 'vite.config.js': 'export default {};\n' });
const forbiddenOut = join(BASE, 'forbidden-entry-out', 'xhs-minitool.zip');
assert.throws(
() => packMinitool({
cwd: BASE,
viteBuiltDir: 'forbidden-entry',
zipOut: forbiddenOut,
}),
/禁止文件/,
);
assert.equal(existsSync(forbiddenOut), false, '含禁止条目时不应写出 zip');
});
test('干净产物目录仍能正常打包', () => {
writeBuiltDir('clean');
const zipOut = join(BASE, 'clean-out', 'xhs-minitool.zip');
@@ -150,8 +150,8 @@ function collectZipEntries(rootDir) {
return entries;
}
export function writeZip(rootDir, zipPath) {
const entries = collectZipEntries(rootDir);
/** 把已收集的条目编码成完整 zip 字节。只算内存,不落盘——校验没过时磁盘上不能留半成品。 */
function encodeZip(entries) {
const now = new Date();
const dosTime =
((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
@@ -235,8 +235,15 @@ export function writeZip(rootDir, zipPath) {
eocd.writeUInt32LE(localBuf.length, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([localBuf, centralBuf, eocd]);
}
/** 落盘版:写 zip 并返回条目名列表(保持既有导出名,供只想要 zip 的调用方使用)。 */
export function writeZip(rootDir, zipPath) {
const entries = collectZipEntries(rootDir);
const buffer = encodeZip(entries);
rmSync(zipPath, { force: true });
writeFileSync(zipPath, Buffer.concat([localBuf, centralBuf, eocd]));
writeFileSync(zipPath, buffer);
return entries.map((entry) => entry.name);
}
@@ -258,20 +265,27 @@ export function auditZipFile(file, size) {
}
function makeZip(viteBuiltDir, zipPath) {
const entries = writeZip(viteBuiltDir, zipPath);
if (!entries.includes('index.html')) {
// 先把条目和字节都算完、校验通过,最后才落盘:中途失败时预期输出路径上不能留一份半成品,
// 更不能让宿主把半成品当成本次构建的产物。
const entries = collectZipEntries(viteBuiltDir);
const names = entries.map((entry) => entry.name);
if (!names.includes('index.html')) {
throw new Error('zip 根目录缺少 index.html(可能多套了一层目录)');
}
const bad = entries.find((entry) => FORBIDDEN_ENTRY.test(entry));
const bad = names.find((entry) => FORBIDDEN_ENTRY.test(entry));
if (bad) {
throw new Error(`zip 内含禁止文件:${bad}`);
}
const size = statSync(zipPath).size;
for (const item of auditZipFile(zipPath, size)) {
const buffer = encodeZip(entries);
for (const item of auditZipFile(zipPath, buffer.length)) {
if (item.severity === 'ERROR') throw new Error(item.message);
console.warn(`[warn] ${item.message}`);
}
console.log(`zip 已生成:${zipPath}(${entries.length} 个条目,${(size / MIB).toFixed(2)} MiB)`);
rmSync(zipPath, { force: true });
writeFileSync(zipPath, buffer);
console.log(
`zip 已生成:${zipPath}(${entries.length} 个条目,${(buffer.length / MIB).toFixed(2)} MiB)`,
);
}
/**