diff --git a/readme.md b/readme.md index b45dda7..87f3aeb 100644 --- a/readme.md +++ b/readme.md @@ -189,6 +189,24 @@ npm run pack:harmony 📌 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 【常见错误】 **❌ `hvigor ERROR: 00303218 Configuration Error`** diff --git a/scripts/pack-harmony-project.js b/scripts/pack-harmony-project.js index d3434ed..6e4670b 100644 --- a/scripts/pack-harmony-project.js +++ b/scripts/pack-harmony-project.js @@ -312,6 +312,141 @@ function humanSize(bytes) { 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) { const required = [ @@ -354,6 +489,10 @@ async function main() { await fsp.mkdir(stageDir, { recursive: true }); const t0 = Date.now(); + // stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍 + stats.files = 0; + stats.bytes = 0; + stats.skipped = 0; await copyDir(srcRoot, stageDir, opts); console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`); @@ -419,7 +558,10 @@ async function main() { } catch (e) { /* 非 git 环境忽略 */ } 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; // 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读 @@ -428,7 +570,8 @@ async function main() { console.log(`\n✅ 完成`); console.log(` ZIP: ${shownZip}`); 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 }); @@ -438,7 +581,22 @@ async function main() { } } -main().catch((err) => { - console.error('❌ 打包失败:', err.message); - process.exitCode = 1; -}); +// 导出内部实现供单元测试使用; 作为命令行执行时才跑 main() +module.exports = { + 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; + }); +} diff --git a/tests/pack-harmony-project.test.js b/tests/pack-harmony-project.test.js new file mode 100644 index 0000000..551d7e8 --- /dev/null +++ b/tests/pack-harmony-project.test.js @@ -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 应排除'); +});