/** * 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 应排除'); });