test:鸿蒙工程包导出增加产出自检与单元测试
手写 ZIP 编码器的典型 bug 都是静默的 —— 包能生成、看着正常, 但某些机器上 解不开或解出畸形路径。开发期已真实踩过两次(中央目录偏移漏算文件数据长度、 localOffset 记成数据区起点), 两次都只有靠 unzip -t 才发现, 因此加两层保障。 一、脚本内产出自检 (每次导出都跑) 新增 verifyZipFile(), 把写好的 zip 重新解析一遍, 任一项不符直接抛错、不输出 「完成」。刻意用独立解析实现, 不复用写入端逻辑, 避免自证其说: - EOCD / 中央目录: 签名、条目数、偏移与长度边界一致 - localOffset: 每条必须真的指向本地文件头, 且文件名与中央目录一致 - CRC: 逐条解压后重算 CRC32 比对 - 条目名: 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 - 内容断言: 必需文件齐全; node_modules/build/.preview 等排除项不得混入 二、单元测试 tests/pack-harmony-project.test.js (16 条) 正向用例写夹具目录打包再独立解析还原, 校验内容/目录/中文名/零字节/空目录/ 可压缩与不可压缩内容; 反向用例故意篡改 zip 断言自检能抓到 —— 反向用例一旦 失效自检就形同虚设, 故必须保持通过。 过程中自检抓到自身一条误判: 排除校验原先用子串匹配, 把合法源码 entry/src/ohosTest/ets/test/Ability.test.ets 当成 .test 构建产物。已改为按 路径分段精确匹配, 并加了一条反向用例锁定。 同时修掉两个隐患: stats 是模块级累加器(同进程重复调用会翻倍), main() 原先 无条件执行(require 脚本就会跑一遍打包), 现加 require.main 守卫并导出内部 实现供测试使用。 已验证: 本测试文件 16/16 通过, npm test 全量 17 个测试文件通过, 实打包 2.99MiB 包自检通过且 unzip -t 交叉验证无错误。
This commit is contained in:
18
readme.md
18
readme.md
@@ -189,6 +189,24 @@ npm run pack:harmony
|
|||||||
|
|
||||||
📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。
|
📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。
|
||||||
|
|
||||||
|
**产出后自检(每次导出都会跑)**
|
||||||
|
|
||||||
|
脚本写完 zip 后会把文件**重新解析一遍**,任何一项不符直接判失败、不输出「完成」:
|
||||||
|
|
||||||
|
| 校验项 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 |
|
||||||
|
| `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 |
|
||||||
|
| CRC | **逐条**解压后重算 CRC32 比对 |
|
||||||
|
| 条目名 | 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 |
|
||||||
|
| 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 16 条用例
|
||||||
|
```
|
||||||
|
|
||||||
|
> 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。
|
||||||
|
|
||||||
#### 4.3.4 【常见错误】
|
#### 4.3.4 【常见错误】
|
||||||
|
|
||||||
**❌ `hvigor ERROR: 00303218 Configuration Error`**
|
**❌ `hvigor ERROR: 00303218 Configuration Error`**
|
||||||
|
|||||||
@@ -312,6 +312,141 @@ function humanSize(bytes) {
|
|||||||
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
|
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
* 产出后自检
|
||||||
|
*
|
||||||
|
* 手写 ZIP 编码器最容易出的两类 bug 都是**静默**的:
|
||||||
|
* - 偏移算错 -> 文件内容对但部分条目解不出来 (不校验就发现不了)
|
||||||
|
* - 标志位/分隔符写错 -> Windows 能解开, macOS/Linux 解出畸形路径
|
||||||
|
* 所以每次导出后都把 zip 重新解析一遍, 任何一项不符就判失败, 不输出「完成」。
|
||||||
|
*
|
||||||
|
* 刻意不复用写入端的逻辑: 这里用独立的解析实现, 避免自证其说。
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} zipPath
|
||||||
|
* @param {{withCerts?: boolean, skipRequired?: boolean}} opts
|
||||||
|
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
|
||||||
|
* @returns {Promise<{entries:number, files:number, dirs:number}>}
|
||||||
|
* @throws {Error} 任一项校验不通过
|
||||||
|
*/
|
||||||
|
async function verifyZipFile(zipPath, opts = {}) {
|
||||||
|
const buf = await fsp.readFile(zipPath);
|
||||||
|
const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); };
|
||||||
|
|
||||||
|
// --- 定位并校验 EOCD (从尾部往前扫, 兼容可能存在的注释) ---
|
||||||
|
const EOCD_MIN = 22;
|
||||||
|
if (buf.length < EOCD_MIN) fail('文件小于 EOCD 最小长度');
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - EOCD_MIN; i >= 0; i--) {
|
||||||
|
if (buf.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
if (eocd === -1) fail('找不到 EOCD 结尾记录');
|
||||||
|
|
||||||
|
const totalEntries = buf.readUInt16LE(eocd + 10);
|
||||||
|
const centralSize = buf.readUInt32LE(eocd + 12);
|
||||||
|
const centralOffset = buf.readUInt32LE(eocd + 16);
|
||||||
|
|
||||||
|
if (centralOffset + centralSize > buf.length) {
|
||||||
|
fail(`中央目录越界 (offset=${centralOffset} size=${centralSize} 文件长=${buf.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 逐条遍历中央目录 ---
|
||||||
|
const names = [];
|
||||||
|
let p = centralOffset;
|
||||||
|
for (let idx = 0; idx < totalEntries; idx++) {
|
||||||
|
if (p + 46 > buf.length) fail(`第 ${idx} 条中央目录记录越界`);
|
||||||
|
if (buf.readUInt32LE(p) !== SIG_CENTRAL) fail(`第 ${idx} 条中央目录签名错误`);
|
||||||
|
|
||||||
|
const flags = buf.readUInt16LE(p + 8);
|
||||||
|
const method = buf.readUInt16LE(p + 10);
|
||||||
|
const crc = buf.readUInt32LE(p + 16);
|
||||||
|
const compSize = buf.readUInt32LE(p + 20);
|
||||||
|
const uncompSize = buf.readUInt32LE(p + 24);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const localOffset = buf.readUInt32LE(p + 42);
|
||||||
|
const nameBuf = buf.subarray(p + 46, p + 46 + nameLen);
|
||||||
|
|
||||||
|
// 名称合法性: 必须正斜杠 + 合法 UTF-8 + 与 UTF-8 标志位一致
|
||||||
|
if (!(flags & FLAG_UTF8)) fail(`第 ${idx} 条缺少 UTF-8 标志位: ${nameBuf.toString('latin1')}`);
|
||||||
|
const name = nameBuf.toString('utf8');
|
||||||
|
if (name.includes('\\')) fail(`条目名含反斜杠 (非 ZIP 规范): ${name}`);
|
||||||
|
if (!Buffer.from(name, 'utf8').equals(nameBuf)) fail(`条目名不是合法 UTF-8: ${nameBuf.toString('hex')}`);
|
||||||
|
names.push(name);
|
||||||
|
|
||||||
|
// 本地文件头: 偏移必须真的指向一个本地头, 且文件名与中央目录一致
|
||||||
|
if (localOffset + 30 > buf.length) fail(`第 ${idx} 条 localOffset 越界: ${localOffset}`);
|
||||||
|
if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) fail(`第 ${idx} 条 localOffset 未指向本地文件头: ${localOffset}`);
|
||||||
|
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||||
|
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||||
|
const localName = buf.subarray(localOffset + 30, localOffset + 30 + lNameLen);
|
||||||
|
if (!localName.equals(nameBuf)) fail(`第 ${idx} 条本地头与中央目录文件名不一致`);
|
||||||
|
|
||||||
|
// 数据区: 解压后长度与 CRC 必须对得上 (目录条目除外)
|
||||||
|
if (!name.endsWith('/')) {
|
||||||
|
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||||
|
const raw = buf.subarray(dataStart, dataStart + compSize);
|
||||||
|
if (raw.length !== compSize) fail(`第 ${idx} 条数据区越界`);
|
||||||
|
let data;
|
||||||
|
if (method === 0) data = raw;
|
||||||
|
else if (method === 8) data = zlib.inflateRawSync(raw);
|
||||||
|
else fail(`第 ${idx} 条压缩方式不支持: ${method}`);
|
||||||
|
if (data.length !== uncompSize) fail(`第 ${idx} 条解压后长度不符 (${data.length} != ${uncompSize})`);
|
||||||
|
if (crc32(data) !== crc) fail(`第 ${idx} 条 CRC 校验失败`);
|
||||||
|
}
|
||||||
|
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (p - centralOffset !== centralSize) {
|
||||||
|
fail(`中央目录实际长度 ${p - centralOffset} != EOCD 记录 ${centralSize}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 内容断言 ---
|
||||||
|
if (!opts.skipRequired) {
|
||||||
|
const required = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/build-profile.json5',
|
||||||
|
'mp-harmony/oh-package.json5',
|
||||||
|
'mp-harmony/hvigor/hvigor-config.json5',
|
||||||
|
'mp-harmony/entry/src/main/module.json5',
|
||||||
|
'mp-harmony/entry/oh-package.json5',
|
||||||
|
'mp-harmony/ascf/ascf_src/app.js',
|
||||||
|
];
|
||||||
|
if (opts.withCerts !== false) {
|
||||||
|
required.push('mp-harmony/cert-profile/qq/qq.p12', 'mp-harmony/cert-profile/qq/qqDebug.p7b');
|
||||||
|
}
|
||||||
|
const missing = required.filter((r) => !names.includes(r));
|
||||||
|
if (missing.length) fail(`缺少构建必需文件: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按**路径分段**判断, 不用子串 —— 否则 .test 会误伤 entry/src/ohosTest/ets/test/
|
||||||
|
// 这类合法源码文件 (.test 排除的是名为 .test 的目录, 不是任何含 .test 的路径)
|
||||||
|
const dirSegmentsOf = (n) => n.split('/').slice(0, -1);
|
||||||
|
const fileNameOf = (n) => n.split('/').pop();
|
||||||
|
const leaked = names.filter((n) => dirSegmentsOf(n).some((s) => EXCLUDE_DIRS.has(s)));
|
||||||
|
if (leaked.length) {
|
||||||
|
fail(`混入了应排除的目录: ${leaked.slice(0, 3).join(', ')}${leaked.length > 3 ? ` ...共 ${leaked.length} 条` : ''}`);
|
||||||
|
}
|
||||||
|
const leakedFiles = names.filter((n) => !n.endsWith('/') && EXCLUDE_FILES.has(fileNameOf(n)));
|
||||||
|
if (leakedFiles.length) {
|
||||||
|
fail(`混入了应排除的文件: ${leakedFiles.slice(0, 3).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.withCerts === false && names.some((n) => n.includes(CERT_DIR))) {
|
||||||
|
fail('--no-certs 生效异常: zip 内仍存在 cert-profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = names.filter((n) => !n.endsWith('/')).length;
|
||||||
|
return {
|
||||||
|
entries: totalEntries,
|
||||||
|
files: fileCount,
|
||||||
|
dirs: totalEntries - fileCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 校验暂存目录里构建必需的骨架文件都在 */
|
/** 校验暂存目录里构建必需的骨架文件都在 */
|
||||||
async function verifySkeleton(dir) {
|
async function verifySkeleton(dir) {
|
||||||
const required = [
|
const required = [
|
||||||
@@ -354,6 +489,10 @@ async function main() {
|
|||||||
await fsp.mkdir(stageDir, { recursive: true });
|
await fsp.mkdir(stageDir, { recursive: true });
|
||||||
|
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
|
// stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍
|
||||||
|
stats.files = 0;
|
||||||
|
stats.bytes = 0;
|
||||||
|
stats.skipped = 0;
|
||||||
await copyDir(srcRoot, stageDir, opts);
|
await copyDir(srcRoot, stageDir, opts);
|
||||||
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
|
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
|
||||||
|
|
||||||
@@ -419,7 +558,10 @@ async function main() {
|
|||||||
} catch (e) { /* 非 git 环境忽略 */ }
|
} catch (e) { /* 非 git 环境忽略 */ }
|
||||||
|
|
||||||
const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`);
|
const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`);
|
||||||
const zres = await zipDirectory(stageDir, zipPath);
|
await zipDirectory(stageDir, zipPath);
|
||||||
|
|
||||||
|
// 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」)
|
||||||
|
const vres = await verifyZipFile(zipPath, { withCerts: opts.withCerts });
|
||||||
|
|
||||||
const zipSize = (await fsp.stat(zipPath)).size;
|
const zipSize = (await fsp.stat(zipPath)).size;
|
||||||
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
|
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
|
||||||
@@ -428,7 +570,8 @@ async function main() {
|
|||||||
console.log(`\n✅ 完成`);
|
console.log(`\n✅ 完成`);
|
||||||
console.log(` ZIP: ${shownZip}`);
|
console.log(` ZIP: ${shownZip}`);
|
||||||
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
|
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
|
||||||
console.log(` 条目: ${zres.files} 个文件 + ${zres.dirs} 个目录, 条目名统一正斜杠 + UTF-8`);
|
console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`);
|
||||||
|
console.log(` 自检: CRC 逐条校验 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`);
|
||||||
|
|
||||||
// 清暂存目录
|
// 清暂存目录
|
||||||
await fsp.rm(stageParent, { recursive: true, force: true });
|
await fsp.rm(stageParent, { recursive: true, force: true });
|
||||||
@@ -438,7 +581,22 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((err) => {
|
// 导出内部实现供单元测试使用; 作为命令行执行时才跑 main()
|
||||||
console.error('❌ 打包失败:', err.message);
|
module.exports = {
|
||||||
process.exitCode = 1;
|
crc32,
|
||||||
});
|
toDosDateTime,
|
||||||
|
zipDirectory,
|
||||||
|
verifyZipFile,
|
||||||
|
shouldSkipDir,
|
||||||
|
shouldSkipFile,
|
||||||
|
copyDir,
|
||||||
|
EXCLUDE_DIRS,
|
||||||
|
EXCLUDE_FILES,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('❌ 打包失败:', err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
378
tests/pack-harmony-project.test.js
Normal file
378
tests/pack-harmony-project.test.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* scripts/pack-harmony-project.js 单元测试
|
||||||
|
*
|
||||||
|
* 跑法:
|
||||||
|
* node --test tests/pack-harmony-project.test.js
|
||||||
|
*
|
||||||
|
* 为什么这个脚本需要测试:
|
||||||
|
* 它**手写了 ZIP 编码格式**(没有用系统命令 / 第三方库), 而这类代码的典型 bug
|
||||||
|
* 都是静默的 —— 包能生成、看着正常, 但某些机器上解不开或解出畸形路径。
|
||||||
|
* 历史上已经踩过两次:
|
||||||
|
* 1. 漏算文件数据长度, 中央目录偏移偏小 -> "N extra bytes at beginning"
|
||||||
|
* 2. localOffset 记成了数据区起点而非本地头起点 -> "bad zipfile offset"
|
||||||
|
* 两者都不会抛异常, 只有真正解压/解析才会暴露。
|
||||||
|
*
|
||||||
|
* 所以测试分两类:
|
||||||
|
* - 正向: 写出来的 zip 结构合法、内容可还原
|
||||||
|
* - **反向**: 故意损坏 zip, 断言 verifyZipFile 真的能抓到
|
||||||
|
* (否则自检可能形同虚设, 永远返回 true)
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const zlib = require('node:zlib');
|
||||||
|
|
||||||
|
const {
|
||||||
|
crc32,
|
||||||
|
toDosDateTime,
|
||||||
|
zipDirectory,
|
||||||
|
verifyZipFile,
|
||||||
|
shouldSkipDir,
|
||||||
|
shouldSkipFile,
|
||||||
|
EXCLUDE_DIRS,
|
||||||
|
EXCLUDE_FILES,
|
||||||
|
} = require('../scripts/pack-harmony-project');
|
||||||
|
|
||||||
|
/** 建临时目录, 测试结束自动清理 */
|
||||||
|
function tmpDir(t) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-harmony-test-'));
|
||||||
|
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建一个夹具目录, 内含各种体积/编码的文件 */
|
||||||
|
function makeFixture(root) {
|
||||||
|
fs.mkdirSync(path.join(root, 'AppScope'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, 'a/b/c'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(root, 'empty-dir'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(root, 'AppScope/app.json5'), '{"app":{"bundleName":"x"}}');
|
||||||
|
fs.writeFileSync(path.join(root, 'a/b/c/deep.txt'), 'deep content');
|
||||||
|
// 可压缩内容 (应走 deflate)
|
||||||
|
fs.writeFileSync(path.join(root, 'a/repeatable.txt'), 'abc'.repeat(2000));
|
||||||
|
// 不可压缩内容 (应退回 stored)
|
||||||
|
fs.writeFileSync(path.join(root, 'a/random.bin'), require('node:crypto').randomBytes(4096));
|
||||||
|
// 中文文件名
|
||||||
|
fs.writeFileSync(path.join(root, 'a/中文名.txt'), '中文内容');
|
||||||
|
// 零字节文件
|
||||||
|
fs.writeFileSync(path.join(root, 'a/zero.txt'), '');
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* 独立 zip 读取器 (刻意不复用写入端实现, 避免自证其说)
|
||||||
|
* 只实现测试需要的最小功能: 按条目名取回内容
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
function readZipEntries(zipPath) {
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) {
|
||||||
|
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
}
|
||||||
|
assert.ok(eocd !== -1, '应能找到 EOCD');
|
||||||
|
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
const out = new Map();
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
assert.equal(buf.readUInt32LE(p), 0x02014b50, `第 ${i} 条中央目录签名应正确`);
|
||||||
|
const method = buf.readUInt16LE(p + 10);
|
||||||
|
const crc = buf.readUInt32LE(p + 16);
|
||||||
|
const compSize = buf.readUInt32LE(p + 20);
|
||||||
|
const uncompSize = buf.readUInt32LE(p + 24);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const localOffset = buf.readUInt32LE(p + 42);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
|
||||||
|
if (!name.endsWith('/')) {
|
||||||
|
const lNameLen = buf.readUInt16LE(localOffset + 26);
|
||||||
|
const lExtraLen = buf.readUInt16LE(localOffset + 28);
|
||||||
|
const start = localOffset + 30 + lNameLen + lExtraLen;
|
||||||
|
const raw = buf.subarray(start, start + compSize);
|
||||||
|
const data = method === 0 ? raw : zlib.inflateRawSync(raw);
|
||||||
|
assert.equal(data.length, uncompSize, `${name} 解压长度应匹配`);
|
||||||
|
assert.equal(crc32(data), crc, `${name} CRC 应匹配`);
|
||||||
|
out.set(name, data);
|
||||||
|
} else {
|
||||||
|
out.set(name, null); // 目录条目
|
||||||
|
}
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 纯函数 ============================ */
|
||||||
|
|
||||||
|
test('crc32: 标准校验向量', () => {
|
||||||
|
// CRC-32/ISO-HDLC 的标准 check value
|
||||||
|
assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
|
||||||
|
assert.equal(crc32(Buffer.from('')), 0);
|
||||||
|
assert.equal(crc32(Buffer.from('a')), 0xe8b7be43);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toDosDateTime: 字段不溢出且能反解', () => {
|
||||||
|
const d = new Date(2026, 9, 17, 14, 30, 20);
|
||||||
|
const { time, date } = toDosDateTime(d);
|
||||||
|
assert.equal((date >> 9) & 0x7f, 2026 - 1980, '年份');
|
||||||
|
assert.equal((date >> 5) & 0xf, 10, '月份');
|
||||||
|
assert.equal(date & 0x1f, 17, '日');
|
||||||
|
assert.equal((time >> 11) & 0x1f, 14, '时');
|
||||||
|
assert.equal((time >> 5) & 0x3f, 30, '分');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ======================= 排除规则 (回归锁定) ======================= */
|
||||||
|
|
||||||
|
test('shouldSkipDir: node_modules/oh_modules/build 等一律排除', () => {
|
||||||
|
const opts = { withNodeModules: false, withCerts: true };
|
||||||
|
for (const d of ['node_modules', 'oh_modules', 'build', '.preview', '.idea', '.hvigor', '.cxx', '.test', '.git']) {
|
||||||
|
assert.equal(shouldSkipDir(d, opts, false), true, `${d} 应被排除`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipDir: --with-node-modules 只放开根级 node_modules', () => {
|
||||||
|
const opts = { withNodeModules: true, withCerts: true };
|
||||||
|
assert.equal(shouldSkipDir('node_modules', opts, true), false, '根级 node_modules 应保留');
|
||||||
|
assert.equal(shouldSkipDir('node_modules', opts, false), true, '非根级 node_modules 仍应排除');
|
||||||
|
assert.equal(shouldSkipDir('oh_modules', opts, true), true, 'oh_modules 始终排除');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipDir: cert-profile 受 --no-certs 控制', () => {
|
||||||
|
assert.equal(shouldSkipDir('cert-profile', { withCerts: true }, false), false);
|
||||||
|
assert.equal(shouldSkipDir('cert-profile', { withCerts: false }, false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shouldSkipFile: local.properties / 编辑器临时文件排除', () => {
|
||||||
|
for (const f of ['local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', 'build.log']) {
|
||||||
|
assert.equal(shouldSkipFile(f), true, `${f} 应被排除`);
|
||||||
|
}
|
||||||
|
// 回归锁定: 这些是**源码**, 不能误伤
|
||||||
|
for (const f of ['module.json5', 'app.json', 'Ability.test.ets', 'List.test.js', 'oh-package.json5']) {
|
||||||
|
assert.equal(shouldSkipFile(f), false, `${f} 不应被排除`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ========================= zip 写入正确性 ========================= */
|
||||||
|
|
||||||
|
test('zipDirectory: 往返后内容与目录结构完全一致', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
|
||||||
|
const res = await zipDirectory(fixture, zipPath);
|
||||||
|
assert.equal(res.files, 6, '夹具应有 6 个文件 (app.json5/deep.txt/repeatable.txt/random.bin/中文名.txt/zero.txt)');
|
||||||
|
|
||||||
|
const entries = readZipEntries(zipPath);
|
||||||
|
const prefix = 'mp-harmony/';
|
||||||
|
const get = (rel) => entries.get(prefix + rel);
|
||||||
|
|
||||||
|
assert.equal(get('AppScope/app.json5').toString(), '{"app":{"bundleName":"x"}}');
|
||||||
|
assert.equal(get('a/b/c/deep.txt').toString(), 'deep content');
|
||||||
|
assert.equal(get('a/repeatable.txt').toString(), 'abc'.repeat(2000));
|
||||||
|
assert.equal(get('a/中文名.txt').toString(), '中文内容');
|
||||||
|
assert.equal(get('a/zero.txt').length, 0, '零字节文件应存在且为空');
|
||||||
|
assert.ok(get('a/random.bin').equals(fs.readFileSync(path.join(fixture, 'a/random.bin'))));
|
||||||
|
|
||||||
|
// 目录条目: 空目录也要保留
|
||||||
|
assert.ok(entries.has(prefix + 'empty-dir/'), '空目录条目应保留');
|
||||||
|
assert.ok(entries.has(prefix + 'a/b/c/'), '嵌套目录条目应保留');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('zipDirectory: 条目名全部用正斜杠且置 UTF-8 标志位', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const flags = buf.readUInt16LE(p + 8);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
|
||||||
|
assert.ok(flags & 0x0800, `第 ${i} 条 ${name} 应置 UTF-8 标志位`);
|
||||||
|
assert.ok(!name.includes('\\'), `第 ${i} 条 ${name} 不应含反斜杠`);
|
||||||
|
assert.ok(!name.startsWith('/'), `第 ${i} 条 ${name} 不应是绝对路径`);
|
||||||
|
assert.ok(!name.includes('//'), `第 ${i} 条 ${name} 不应有连续斜杠`);
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 合法的包应通过', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||||
|
assert.ok(res.files > 0);
|
||||||
|
assert.equal(res.entries, res.files + res.dirs);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ==================== 反向测试: 自检必须能抓到损坏 ==================== */
|
||||||
|
|
||||||
|
test('verifyZipFile: 数据区被篡改应报 CRC 错误', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
// 找一个文件条目的数据区并改掉一个字节
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
const count = buf.readUInt16LE(eocd + 10);
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const compSize = buf.readUInt32LE(p + 20);
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const localOffset = buf.readUInt32LE(p + 42);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
if (!name.endsWith('/') && compSize > 10) {
|
||||||
|
const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
|
||||||
|
buf[start + 2] ^= 0xff; // 翻转数据区中的一个字节
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(zipPath, buf);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/CRC|解压/,
|
||||||
|
'数据被篡改时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: central directory 偏移错误应被拒绝 (回归: 曾漏算数据长度)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
const realOffset = buf.readUInt32LE(eocd + 16);
|
||||||
|
buf.writeUInt32LE(realOffset + 1000, eocd + 16); // 偏移挪 1000 字节
|
||||||
|
fs.writeFileSync(zipPath, buf);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/中央目录/,
|
||||||
|
'中央目录偏移错误时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: localOffset 指向数据区而非本地头应被拒绝 (回归: 曾记成数据起点)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
|
||||||
|
|
||||||
|
const buf = fs.readFileSync(zipPath);
|
||||||
|
let eocd = -1;
|
||||||
|
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
||||||
|
let p = buf.readUInt32LE(eocd + 16);
|
||||||
|
// 把第一条**文件**记录的 localOffset 加 30 (= 跳到数据区)
|
||||||
|
for (let i = 0; i < buf.readUInt16LE(eocd + 10); i++) {
|
||||||
|
const nameLen = buf.readUInt16LE(p + 28);
|
||||||
|
const extraLen = buf.readUInt16LE(p + 30);
|
||||||
|
const commentLen = buf.readUInt16LE(p + 32);
|
||||||
|
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
|
||||||
|
if (!name.endsWith('/')) {
|
||||||
|
buf.writeUInt32LE(buf.readUInt32LE(p + 42) + 30, p + 42);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
p += 46 + nameLen + extraLen + commentLen;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(zipPath, buf);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/localOffset|中央目录签名/,
|
||||||
|
'localOffset 错误时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 混入应排除目录应被拒绝 (回归: 曾用子串匹配误伤 ohosTest/ets/test/)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
// 手工塞一个不该存在的目录
|
||||||
|
fs.mkdirSync(path.join(fixture, 'oh_modules/foo'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'oh_modules/foo/index.js'), 'x');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/应排除/,
|
||||||
|
'混入 oh_modules 时 verifyZipFile 必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 合法源码文件不应被误判为应排除 (回归锁定)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
// 这些路径含 .test / test 字样, 但是**源码**, 必须放行
|
||||||
|
fs.mkdirSync(path.join(fixture, 'entry/src/ohosTest/ets/test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'entry/src/ohosTest/ets/test/Ability.test.ets'), 'x');
|
||||||
|
fs.mkdirSync(path.join(fixture, 'entry/src/test'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'entry/src/test/List.test.ets'), 'x');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
// 不应抛错 (夹具缺必需文件, 用 skipRequired 跳过该断言)
|
||||||
|
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
|
||||||
|
assert.ok(res.files > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
fs.mkdirSync(path.join(fixture, 'cert-profile/qq'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(fixture, 'cert-profile/qq/qq.p12'), 'FAKE');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
|
||||||
|
/nocerts|cert-profile/i,
|
||||||
|
'--no-certs 下混入证书必须报错',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ============================ 导出骨架 ============================ */
|
||||||
|
|
||||||
|
test('copyDir: 排除规则在真实复制中生效', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const src = path.join(root, 'src');
|
||||||
|
const dest = path.join(root, 'dest');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(src, 'keep'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(src, 'node_modules/pkg'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(src, 'build'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(src, 'keep/a.txt'), 'a');
|
||||||
|
fs.writeFileSync(path.join(src, 'node_modules/pkg/i.js'), 'i');
|
||||||
|
fs.writeFileSync(path.join(src, 'build/out.txt'), 'o');
|
||||||
|
fs.writeFileSync(path.join(src, 'local.properties'), 'sdk.dir=x');
|
||||||
|
|
||||||
|
const { copyDir } = require('../scripts/pack-harmony-project');
|
||||||
|
await copyDir(src, dest, { withNodeModules: false, withCerts: true });
|
||||||
|
|
||||||
|
assert.ok(fs.existsSync(path.join(dest, 'keep/a.txt')), '正常文件应保留');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'node_modules')), 'node_modules 应排除');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'build')), 'build 应排除');
|
||||||
|
assert.ok(!fs.existsSync(path.join(dest, 'local.properties')), 'local.properties 应排除');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user