publish-apk 0.0.1-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/hap.js ADDED
@@ -0,0 +1,939 @@
1
+ /**
2
+ * HAP 安装包查看命令(`publish-apk hap`)。
3
+ *
4
+ * 定位:与具体应用市场无关的本地工具——
5
+ * - HAP 本质是 ZIP,读取 pack.info / module.json 输出包名、版本、模块、设备、API 等;
6
+ * - 检测 HAP 签名块(签名块 / Code Sign Block),区分 signed 与 unsigned;
7
+ * - --verbose 追加 ZIP 条目、签名子块与完整 pack.info / module.json。
8
+ *
9
+ * 本命令只读文件,不安装、不验签。签名块存在只说明「带签名结构」,
10
+ * 证书链、摘要、Merkle 树的验签需要额外工具,输出里会明确标注「未验签」。
11
+ * 不引入 npm 第三方依赖:ZIP 解析用 node:zlib,签名状态只解析块头。
12
+ *
13
+ * 退出代码与其它命令一致:0 成功,1 参数错误或文件读取 / 解析失败。
14
+ */
15
+ import { readFileSync } from 'node:fs'
16
+ import { inflateRawSync } from 'node:zlib'
17
+
18
+ import {
19
+ EXIT_OK,
20
+ PROGRAM,
21
+ UsageError,
22
+ isHelpToken,
23
+ parseOptions,
24
+ readEnv,
25
+ stdout,
26
+ } from './cli.js'
27
+ import { certificateDetailLines } from './cert.js'
28
+ import { renderHelp } from './help.js'
29
+ import { parseP7bDer } from './profile.js'
30
+
31
+ /** 命令名 */
32
+ const COMMAND_NAME = 'hap'
33
+ /** HAP 文件路径;必填 */
34
+ const OPTION_FILE = '--file'
35
+ /** 开关:输出全部字段 */
36
+ const OPTION_VERBOSE = '--verbose'
37
+ /** hap 支持的全部选项 */
38
+ const HAP_OPTIONS = new Set([OPTION_FILE, OPTION_VERBOSE])
39
+ /** hap 中的开关型选项 */
40
+ const HAP_FLAGS = new Set([OPTION_VERBOSE])
41
+ const HAP_MULTI_OPTIONS = new Set([OPTION_FILE])
42
+
43
+ /** HAP 文件路径,等价于 --file(必填) */
44
+ const ENV_FILE = 'HAP_FILE'
45
+ /** 开关:输出全部字段,等价于 --verbose */
46
+ const ENV_VERBOSE = 'HAP_VERBOSE'
47
+
48
+ /** app 命令的文件与开关环境变量 */
49
+ const ENV_APP_FILE = 'APP_FILE'
50
+ const ENV_APP_VERBOSE = 'APP_VERBOSE'
51
+
52
+ const VERBOSE_ON_VALUES = new Set(['1', 'true', 'yes', 'on'])
53
+ const VERBOSE_OFF_VALUES = new Set(['0', 'false', 'no', 'off'])
54
+
55
+ /** ZIP 结构签名 */
56
+ const EOCD_SIGNATURE = 0x06054b50
57
+ const CENTRAL_SIGNATURE = 0x02014b50
58
+ const LOCAL_SIGNATURE = 0x04034b50
59
+
60
+ /** HAP 签名块尾部的两个 magic(v2 / v3) */
61
+ const HAP_SIGNATURE_MAGIC_V2 = Buffer.from('HAP Sig Block 42')
62
+ const HAP_SIGNATURE_MAGIC_V3 = Buffer.from('<hap sign block>')
63
+ /** HAP Code Sign Block 头部的 magic(小端 u64 的字节序) */
64
+ const CODE_SIGN_MAGIC = Buffer.from('cdfc8953c6c846e0', 'hex')
65
+
66
+ /** HAP 签名块类型 */
67
+ const SIGN_BLOCK_TYPES = new Map([
68
+ [0x20000000, 'HAP 签名方案 v1'],
69
+ [0x20000001, '未签名 Profile'],
70
+ [0x20000002, '已签名 Profile'],
71
+ [0x20000003, 'Code Sign Block'],
72
+ [0x20000004, '密钥轮换'],
73
+ ])
74
+
75
+ // -------------------------------------------------------------- 开关型参数
76
+
77
+ /** 解析 --verbose / HAP_VERBOSE */
78
+ function resolveVerbose(options, env, help, envName = ENV_VERBOSE) {
79
+ if (options.get(OPTION_VERBOSE) === true) {
80
+ return true
81
+ }
82
+ const value = readEnv(env, envName)
83
+ if (value === undefined) {
84
+ return false
85
+ }
86
+ const normalized = value.trim().toLowerCase()
87
+ if (VERBOSE_ON_VALUES.has(normalized)) {
88
+ return true
89
+ }
90
+ if (VERBOSE_OFF_VALUES.has(normalized)) {
91
+ return false
92
+ }
93
+ throw new UsageError(
94
+ `环境变量 ${envName} 的值无效: "${value}"(开启用 1/true/yes/on,关闭用 0/false/no/off)`,
95
+ help(),
96
+ )
97
+ }
98
+
99
+ // ------------------------------------------------------------------ 帮助文档
100
+
101
+ function renderHapHelp() {
102
+ return renderHelp({
103
+ title: `${PROGRAM} ${COMMAND_NAME} - 查看 HAP 安装包信息`,
104
+ usage: [`${PROGRAM} ${COMMAND_NAME} [选项]`],
105
+ sections: [
106
+ {
107
+ title: '选项',
108
+ entries: [
109
+ [`${OPTION_FILE} <路径...>`, `一个或多个 HAP 文件路径(支持 shell glob),必填(可用 ${ENV_FILE} 补齐)`],
110
+ [
111
+ OPTION_VERBOSE,
112
+ `输出全部字段(ZIP 条目、签名子块、全部签名证书链、完整 pack.info / module.json);命令行未给出时读取 ${ENV_VERBOSE}`,
113
+ ],
114
+ ['-h, --help', '显示帮助信息'],
115
+ ],
116
+ },
117
+ {
118
+ title: '环境变量',
119
+ entries: [
120
+ [ENV_FILE, `HAP 文件路径,等价于 ${OPTION_FILE}`],
121
+ [ENV_VERBOSE, '输出全部字段,1/true/yes/on 开启、0/false/no/off 关闭,等价于 --verbose'],
122
+ ],
123
+ },
124
+ {
125
+ title: '输出',
126
+ entries: [
127
+ ['stdout', 'HAP 包信息与签名证书,每行一条「标签: 值」;--verbose 时追加 ZIP 条目、完整证书链与原始 JSON'],
128
+ ['stderr', '错误信息'],
129
+ ],
130
+ },
131
+ {
132
+ title: '退出代码',
133
+ entries: [
134
+ ['0', '成功'],
135
+ ['1', '参数缺失、参数错误或文件读取 / 解析失败'],
136
+ ],
137
+ },
138
+ ],
139
+ notes: [
140
+ '示例:',
141
+ ` ${PROGRAM} ${COMMAND_NAME} --file ./app-signed.hap`,
142
+ ` ${PROGRAM} ${COMMAND_NAME} --file ./app-unsigned.hap --verbose`,
143
+ ` ${ENV_FILE}=./app.hap ${PROGRAM} ${COMMAND_NAME}`,
144
+ ` ${ENV_VERBOSE}=1 ${PROGRAM} ${COMMAND_NAME} --file ./app.hap`,
145
+ '',
146
+ '说明: --file 可以一次给多个文件(shell glob 也可),按顺序输出,文件之间空一行',
147
+ '说明: HAP 是 ZIP,读取 pack.info / module.json;签名只检测 HAP 签名块并从内嵌 PKCS#7 提取证书,未执行验签',
148
+ '说明: 默认每组证书只输出叶子证书;--verbose 输出完整证书链',
149
+ '说明: signed / unsigned 的包内容通常相同,区别在是否带 HAP 签名块',
150
+ '说明: 不输出证书 PEM 原文;HAP 里没有私钥',
151
+ ],
152
+ })
153
+ }
154
+
155
+ function renderAppHelp() {
156
+ return renderHelp({
157
+ title: `${PROGRAM} app - 查看 APP 安装包信息`,
158
+ usage: [`${PROGRAM} app [选项]`],
159
+ sections: [
160
+ {
161
+ title: '选项',
162
+ entries: [
163
+ [`${OPTION_FILE} <路径...>`, `一个或多个 APP 文件路径(支持 shell glob),必填(可用 ${ENV_APP_FILE} 补齐)`],
164
+ [
165
+ OPTION_VERBOSE,
166
+ `输出全部字段(ZIP 条目、签名子块、全部签名证书链、完整 pack.info / pac.json);命令行未给出时读取 ${ENV_APP_VERBOSE}`,
167
+ ],
168
+ ['-h, --help', '显示帮助信息'],
169
+ ],
170
+ },
171
+ {
172
+ title: '环境变量',
173
+ entries: [
174
+ [ENV_APP_FILE, `APP 文件路径,等价于 ${OPTION_FILE}`],
175
+ [ENV_APP_VERBOSE, '输出全部字段,1/true/yes/on 开启、0/false/no/off 关闭,等价于 --verbose'],
176
+ ],
177
+ },
178
+ {
179
+ title: '输出',
180
+ entries: [
181
+ ['stdout', 'APP 包信息、内层模块与签名证书,每行一条「标签: 值」;--verbose 时追加 ZIP 条目、完整证书链与原始 JSON'],
182
+ ['stderr', '错误信息'],
183
+ ],
184
+ },
185
+ {
186
+ title: '退出代码',
187
+ entries: [
188
+ ['0', '成功'],
189
+ ['1', '参数缺失、参数错误或文件读取 / 解析失败'],
190
+ ],
191
+ },
192
+ ],
193
+ notes: [
194
+ '示例:',
195
+ ` ${PROGRAM} app --file ./app-signed.app`,
196
+ ` ${PROGRAM} app --file ./app-unsigned.app --verbose`,
197
+ ` ${ENV_APP_FILE}=./app.app ${PROGRAM} app`,
198
+ ` ${ENV_APP_VERBOSE}=1 ${PROGRAM} app --file ./app.app`,
199
+ '',
200
+ '说明: --file 可以一次给多个文件(shell glob 也可),按顺序输出,文件之间空一行',
201
+ '说明: APP 是 ZIP,含 pac.json、pack.info 与一个或多个内层 .hap / .hsp;本命令读取外层信息',
202
+ '说明: 签名块在外层,内层 HAP 可能没有单独签名;签名证书从外层 PKCS#7 提取,未执行验签',
203
+ '说明: 默认每组证书只输出叶子证书;--verbose 输出完整证书链',
204
+ '说明: 不输出证书 PEM 原文;APP 里没有私钥',
205
+ ],
206
+ })
207
+ }
208
+
209
+ // ------------------------------------------------------------------ ZIP 解析
210
+
211
+ /** 从文件尾部找 EOCD,返回中央目录位置与条目数 */
212
+ export function readEndOfCentralDirectory(buffer, help) {
213
+ const min = Math.max(0, buffer.length - 22 - 0xffff)
214
+ for (let offset = buffer.length - 22; offset >= min; offset -= 1) {
215
+ if (buffer.readUInt32LE(offset) !== EOCD_SIGNATURE) {
216
+ continue
217
+ }
218
+ const commentLength = buffer.readUInt16LE(offset + 20)
219
+ if (offset + 22 + commentLength !== buffer.length) {
220
+ continue
221
+ }
222
+ const count = buffer.readUInt16LE(offset + 10)
223
+ const centralSize = buffer.readUInt32LE(offset + 12)
224
+ const centralOffset = buffer.readUInt32LE(offset + 16)
225
+ if (count === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) {
226
+ throw new UsageError('暂不支持 ZIP64 格式的 HAP', help())
227
+ }
228
+ if (centralOffset + centralSize > buffer.length) {
229
+ throw new UsageError('ZIP 中央目录越界,文件可能已损坏', help())
230
+ }
231
+ return { count, centralOffset, centralSize }
232
+ }
233
+ throw new UsageError('不是有效的 ZIP / HAP(找不到 EOCD)', help())
234
+ }
235
+
236
+ /** 读取中央目录里的条目元数据(不立即解压) */
237
+ export function readZipEntries(buffer, eocd, help) {
238
+ const entries = []
239
+ let offset = eocd.centralOffset
240
+ for (let index = 0; index < eocd.count; index += 1) {
241
+ if (offset + 46 > buffer.length || buffer.readUInt32LE(offset) !== CENTRAL_SIGNATURE) {
242
+ throw new UsageError(`ZIP 中央目录损坏(第 ${index + 1} 个条目)`, help())
243
+ }
244
+ const flags = buffer.readUInt16LE(offset + 8)
245
+ const method = buffer.readUInt16LE(offset + 10)
246
+ const compressedSize = buffer.readUInt32LE(offset + 20)
247
+ const size = buffer.readUInt32LE(offset + 24)
248
+ const nameLength = buffer.readUInt16LE(offset + 28)
249
+ const extraLength = buffer.readUInt16LE(offset + 30)
250
+ const commentLength = buffer.readUInt16LE(offset + 32)
251
+ const localOffset = buffer.readUInt32LE(offset + 42)
252
+ const nameStart = offset + 46
253
+ const nameEnd = nameStart + nameLength
254
+ if (nameEnd > buffer.length) {
255
+ throw new UsageError('ZIP 条目名越界,文件可能已损坏', help())
256
+ }
257
+ if (compressedSize === 0xffffffff || size === 0xffffffff || localOffset === 0xffffffff) {
258
+ throw new UsageError('暂不支持 ZIP64 格式的 HAP', help())
259
+ }
260
+ entries.push({
261
+ name: buffer.subarray(nameStart, nameEnd).toString('utf8'),
262
+ flags,
263
+ method,
264
+ compressedSize,
265
+ size,
266
+ localOffset,
267
+ })
268
+ offset = nameEnd + extraLength + commentLength
269
+ }
270
+ return entries
271
+ }
272
+
273
+ /** 按文件名找条目;兼容前面带目录的打包方式 */
274
+ export function findZipEntry(entries, name) {
275
+ return (
276
+ entries.find((entry) => entry.name === name) ??
277
+ entries.find((entry) => entry.name.endsWith(`/${name}`)) ??
278
+ null
279
+ )
280
+ }
281
+
282
+ /** 解压单个 ZIP 条目 */
283
+ export function readZipEntryData(buffer, entry, help) {
284
+ if ((entry.flags & 0x1) !== 0) {
285
+ throw new UsageError(`ZIP 条目已加密,无法读取: ${entry.name}`, help())
286
+ }
287
+ if (entry.localOffset + 30 > buffer.length) {
288
+ throw new UsageError(`ZIP 条目的本地头越界: ${entry.name}`, help())
289
+ }
290
+ if (buffer.readUInt32LE(entry.localOffset) !== LOCAL_SIGNATURE) {
291
+ throw new UsageError(`ZIP 条目的本地头损坏: ${entry.name}`, help())
292
+ }
293
+ const nameLength = buffer.readUInt16LE(entry.localOffset + 26)
294
+ const extraLength = buffer.readUInt16LE(entry.localOffset + 28)
295
+ const dataStart = entry.localOffset + 30 + nameLength + extraLength
296
+ const dataEnd = dataStart + entry.compressedSize
297
+ if (dataEnd > buffer.length) {
298
+ throw new UsageError(`ZIP 条目数据越界: ${entry.name}`, help())
299
+ }
300
+ const compressed = buffer.subarray(dataStart, dataEnd)
301
+ let data
302
+ if (entry.method === 0) {
303
+ data = compressed
304
+ } else if (entry.method === 8) {
305
+ try {
306
+ data = inflateRawSync(compressed)
307
+ } catch (error) {
308
+ throw new UsageError(`ZIP 条目解压失败: ${entry.name}: ${error.message}`, help())
309
+ }
310
+ } else {
311
+ throw new UsageError(`不支持的 ZIP 压缩方法 ${entry.method}: ${entry.name}`, help())
312
+ }
313
+ if (data.length !== entry.size) {
314
+ throw new UsageError(
315
+ `ZIP 条目解压后大小不一致: ${entry.name}(${data.length} != ${entry.size})`,
316
+ help(),
317
+ )
318
+ }
319
+ return data
320
+ }
321
+
322
+ /** 读取并解析 JSON 条目;不存在返回 null */
323
+ function readJsonEntry(buffer, entries, name, help) {
324
+ const entry = findZipEntry(entries, name)
325
+ if (entry === null) {
326
+ return null
327
+ }
328
+ let text = readZipEntryData(buffer, entry, help).toString('utf8')
329
+ if (text.charCodeAt(0) === 0xfeff) {
330
+ text = text.slice(1)
331
+ }
332
+ try {
333
+ return JSON.parse(text)
334
+ } catch (error) {
335
+ throw new UsageError(`HAP 的 ${name} 不是合法 JSON: ${error.message}`, help())
336
+ }
337
+ }
338
+
339
+ // ------------------------------------------------------------------ 签名块
340
+
341
+ /**
342
+ * 读取 HAP 签名块。
343
+ *
344
+ * 签名块紧邻 ZIP 中央目录之前,尾部固定为:
345
+ * u64 size
346
+ * u128 magic
347
+ * u32 version
348
+ * 因此 magic 位于 centralOffset - 20,size 位于 centralOffset - 28。
349
+ */
350
+ function readHapSignature(buffer, centralOffset) {
351
+ if (centralOffset < 32) {
352
+ return null
353
+ }
354
+ const magicOffset = centralOffset - 20
355
+ const magic = buffer.subarray(magicOffset, magicOffset + 16)
356
+ let magicVersion
357
+ if (magic.equals(HAP_SIGNATURE_MAGIC_V3)) {
358
+ magicVersion = 3
359
+ } else if (magic.equals(HAP_SIGNATURE_MAGIC_V2)) {
360
+ magicVersion = 2
361
+ } else {
362
+ // 少数只带 Code Sign Block 的包没有 HAP 签名块;校验头部,避免把普通资源里的
363
+ // 同名字节误判成签名。
364
+ const codeOffset = buffer.indexOf(CODE_SIGN_MAGIC)
365
+ if (codeOffset < 0 || codeOffset + 32 > centralOffset) {
366
+ return null
367
+ }
368
+ const codeVersion = buffer.readUInt32LE(codeOffset + 8)
369
+ const codeBlockSize = buffer.readUInt32LE(codeOffset + 12)
370
+ const codeSegmentNum = buffer.readUInt32LE(codeOffset + 16)
371
+ const codeFlags = buffer.readUInt32LE(codeOffset + 20)
372
+ if (
373
+ codeVersion !== 1 ||
374
+ codeSegmentNum !== 3 ||
375
+ codeBlockSize < 32 ||
376
+ codeOffset + codeBlockSize > centralOffset ||
377
+ codeFlags < 0 ||
378
+ codeFlags > 3
379
+ ) {
380
+ return null
381
+ }
382
+ return {
383
+ fallback: true,
384
+ version: codeVersion,
385
+ size: codeBlockSize,
386
+ count: 0,
387
+ start: codeOffset,
388
+ blocks: [],
389
+ }
390
+ }
391
+
392
+ const version = buffer.readUInt32LE(centralOffset - 4)
393
+ const sizeBig = buffer.readBigUInt64LE(centralOffset - 28)
394
+ const count = buffer.readUInt32LE(centralOffset - 32)
395
+ if (sizeBig > BigInt(Number.MAX_SAFE_INTEGER)) {
396
+ return {
397
+ magicVersion,
398
+ version,
399
+ size: 0,
400
+ count,
401
+ start: centralOffset,
402
+ blocks: [],
403
+ invalid: true,
404
+ }
405
+ }
406
+ const size = Number(sizeBig)
407
+ const start = centralOffset - size
408
+ if (!Number.isSafeInteger(size) || size < 32 || start < 0 || start >= centralOffset) {
409
+ return {
410
+ magicVersion,
411
+ version,
412
+ size,
413
+ count,
414
+ start,
415
+ blocks: [],
416
+ invalid: true,
417
+ }
418
+ }
419
+
420
+ const blocks = []
421
+ for (let index = 0; index < count; index += 1) {
422
+ const header = start + index * 12
423
+ if (header + 12 > centralOffset) {
424
+ break
425
+ }
426
+ const type = buffer.readUInt32LE(header)
427
+ const length = buffer.readUInt32LE(header + 4)
428
+ const offset = buffer.readUInt32LE(header + 8)
429
+ const valueStart = start + offset
430
+ const valueEnd = valueStart + length
431
+ blocks.push({
432
+ type,
433
+ length,
434
+ offset,
435
+ valueStart,
436
+ valueEnd,
437
+ valueValid: valueStart >= start && valueEnd <= centralOffset,
438
+ })
439
+ }
440
+ return { magicVersion, version, size, count, start, blocks }
441
+ }
442
+
443
+ /** 签名块状态文案 */
444
+ function signatureLabel(signature, kind = 'HAP') {
445
+ const blockName = kind === 'APP' ? 'APP 签名块' : 'HAP 签名块'
446
+ if (signature === null) {
447
+ return `未签名(未检测到 ${blockName})`
448
+ }
449
+ if (signature.fallback) {
450
+ return `已签名(检测到 ${kind} Code Sign Block;未验签)`
451
+ }
452
+ if (signature.invalid) {
453
+ return `已签名(${blockName}头异常;未验签)`
454
+ }
455
+ const version =
456
+ signature.version === undefined || signature.version === null
457
+ ? ''
458
+ : ` v${signature.version}`
459
+ return `已签名(${blockName}${version},${signature.count} 个子块;未验签)`
460
+ }
461
+
462
+ /** 从 HAP 签名块里的 PKCS#7 子块提取证书链 */
463
+ function collectHapSignatureCertificates(signature, buffer) {
464
+ if (signature === null || signature.fallback || signature.invalid) {
465
+ return []
466
+ }
467
+ const groups = []
468
+ for (const block of signature.blocks) {
469
+ if (block.type !== 0x20000000 && block.type !== 0x20000002) {
470
+ continue
471
+ }
472
+ const label = block.type === 0x20000000 ? 'HAP 签名方案 v1' : 'Profile 签名'
473
+ if (!block.valueValid) {
474
+ groups.push({ label, certificates: [], error: '签名子块数据越界' })
475
+ continue
476
+ }
477
+ try {
478
+ const p7b = parseP7bDer(buffer.subarray(block.valueStart, block.valueEnd))
479
+ groups.push({ label, certificates: p7b.certificates })
480
+ } catch (error) {
481
+ groups.push({ label, certificates: [], error: error.message })
482
+ }
483
+ }
484
+ return groups.sort((left, right) => {
485
+ const order = (label) => (label === 'HAP 签名方案 v1' ? 0 : 1)
486
+ return order(left.label) - order(right.label)
487
+ })
488
+ }
489
+
490
+ /** 证书链里找出叶子证书(不作为任何其它证书的颁发者);找不到时退回第一张 */
491
+ function leafCertificate(certificates) {
492
+ const issuerSubjects = new Set(certificates.map((certificate) => certificate.issuer))
493
+ return (
494
+ certificates.find((certificate) => !issuerSubjects.has(certificate.subject)) ??
495
+ certificates[0]
496
+ )
497
+ }
498
+
499
+ /** 证书链排序:叶子 -> 中间 CA -> 根,找不到的证书按原顺序追加 */
500
+ function orderCertificateChain(certificates) {
501
+ const leaf = leafCertificate(certificates)
502
+ const bySubject = new Map(certificates.map((certificate) => [certificate.subject, certificate]))
503
+ const ordered = [leaf]
504
+ const seen = new Set([leaf])
505
+ let current = leaf
506
+ while (true) {
507
+ const issuer = bySubject.get(current.issuer)
508
+ if (issuer === undefined || seen.has(issuer)) {
509
+ break
510
+ }
511
+ ordered.push(issuer)
512
+ seen.add(issuer)
513
+ current = issuer
514
+ }
515
+ for (const certificate of certificates) {
516
+ if (!seen.has(certificate)) {
517
+ ordered.push(certificate)
518
+ }
519
+ }
520
+ return ordered
521
+ }
522
+
523
+ /** 输出签名证书;默认只输出每组的第一张,--verbose 输出完整证书链 */
524
+ export function addSignatureCertificates(lines, groups, verbose) {
525
+ if (groups.length === 0) {
526
+ return
527
+ }
528
+ lines.push('', '签名证书:')
529
+ for (const group of groups) {
530
+ if (group.error !== undefined) {
531
+ lines.push(`${group.label}: 解析失败(${group.error})`)
532
+ continue
533
+ }
534
+ if (group.certificates.length === 0) {
535
+ lines.push(`${group.label}: 未找到证书`)
536
+ continue
537
+ }
538
+ lines.push(`${group.label}: ${group.certificates.length} 张`)
539
+ const chain = orderCertificateChain(group.certificates)
540
+ const selected = verbose ? chain : chain.slice(0, 1)
541
+ selected.forEach((certificate, index) => {
542
+ lines.push(`证书 #${index + 1}:`)
543
+ for (const detail of certificateDetailLines(certificate, verbose)) {
544
+ lines.push(` ${detail}`)
545
+ }
546
+ })
547
+ if (!verbose && group.certificates.length > 1) {
548
+ lines.push(` (证书链其余 ${group.certificates.length - 1} 张用 --verbose 查看)`)
549
+ }
550
+ }
551
+ }
552
+
553
+ // ------------------------------------------------------------------ 输出
554
+
555
+ function pick(...values) {
556
+ return values.find((value) => value !== undefined && value !== null && value !== '')
557
+ }
558
+
559
+ function values(value) {
560
+ return Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]
561
+ }
562
+
563
+ function names(value) {
564
+ return values(value)
565
+ .map((item) => (typeof item === 'string' ? item : item?.name))
566
+ .filter((item) => typeof item === 'string' && item !== '')
567
+ }
568
+
569
+ export function formatBytes(size) {
570
+ const units = ['B', 'KiB', 'MiB', 'GiB']
571
+ let value = size
572
+ let index = 0
573
+ while (value >= 1024 && index < units.length - 1) {
574
+ value /= 1024
575
+ index += 1
576
+ }
577
+ return index === 0 ? `${size} B` : `${value.toFixed(1)} ${units[index]}`
578
+ }
579
+
580
+ function renderHap(
581
+ path,
582
+ size,
583
+ entries,
584
+ pack,
585
+ moduleJson,
586
+ signature,
587
+ certificateGroups,
588
+ verbose,
589
+ options = {},
590
+ ) {
591
+ const kind = options.kind ?? 'HAP'
592
+ const pac = options.pac ?? null
593
+ const innerModules = options.innerModules ?? []
594
+ const summary = pack?.summary ?? {}
595
+ const packApp = summary.app ?? {}
596
+ const packVersion = packApp.version ?? {}
597
+ const summaryModules = values(summary.modules)
598
+ const summaryModule = summaryModules[0] ?? {}
599
+ const distro = summaryModule.distro ?? {}
600
+ const api = summaryModule.apiVersion ?? {}
601
+ const packageInfo = values(pack?.packages)[0] ?? {}
602
+ const packageNames = values(pack?.packages)
603
+ .map((item) => item?.name)
604
+ .filter((item) => typeof item === 'string' && item !== '')
605
+ const app = moduleJson?.app ?? {}
606
+ const module = moduleJson?.module ?? {}
607
+
608
+ const bundleName = pick(app.bundleName, packApp.bundleName)
609
+ const versionName = pick(app.versionName, packVersion.name)
610
+ const versionCode = pick(app.versionCode, packVersion.code)
611
+ const build = pick(app.buildVersion, packVersion.build)
612
+ const bundleType = pick(app.bundleType, packApp.bundleType)
613
+ const moduleName = pick(module.name, distro.moduleName)
614
+ const moduleType = pick(module.type, distro.moduleType, packageInfo.moduleType)
615
+ const deviceTypes = values(pick(module.deviceTypes, summaryModule.deviceType, packageInfo.deviceType))
616
+ const abilities = names(pick(module.abilities, summaryModule.abilities))
617
+ const mainAbility = pick(summaryModule.mainAbility, module.mainElement)
618
+ const permissions = names(module.requestPermissions)
619
+ const label = pick(app.label)
620
+ const vendor = pick(app.vendor)
621
+ const compileSdk = pick(app.compileSdkVersion)
622
+ const compileSdkType = pick(app.compileSdkType)
623
+ const minApi = pick(api.compatible, app.minAPIVersion)
624
+ const targetApi = pick(api.target, app.targetAPIVersion)
625
+ const releaseType = pick(api.releaseType, app.apiReleaseType)
626
+ const deliveryWithInstall = pick(
627
+ module.deliveryWithInstall,
628
+ distro.deliveryWithInstall,
629
+ packageInfo.deliveryWithInstall,
630
+ )
631
+ const installationFree = pick(module.installationFree, distro.installationFree)
632
+ const buildMode = pick(app.buildMode)
633
+ const debug = typeof app.debug === 'boolean' ? app.debug : undefined
634
+
635
+ const lines = [
636
+ `文件: ${path}`,
637
+ `大小: ${formatBytes(size)}(${size} 字节)`,
638
+ `格式: ${kind} / ZIP(${entries.length} 个条目)`,
639
+ `签名: ${signatureLabel(signature, kind)}`,
640
+ ]
641
+
642
+ if (bundleName !== undefined) {
643
+ lines.push(`包名: ${bundleName}`)
644
+ }
645
+ if (bundleType !== undefined) {
646
+ lines.push(`包类型: ${bundleType}`)
647
+ }
648
+ if (versionName !== undefined || versionCode !== undefined) {
649
+ const version = [versionName, versionCode === undefined ? '' : `(${versionCode})`]
650
+ .filter(Boolean)
651
+ .join(' ')
652
+ lines.push(`版本: ${version}`)
653
+ }
654
+ if (build !== undefined) {
655
+ lines.push(`构建号: ${build}`)
656
+ }
657
+ if (moduleName !== undefined || moduleType !== undefined) {
658
+ const moduleText = [moduleName, moduleType === undefined ? '' : `(${moduleType})`]
659
+ .filter(Boolean)
660
+ .join('')
661
+ lines.push(`模块: ${moduleText}`)
662
+ }
663
+ if (deviceTypes.length > 0) {
664
+ lines.push(`设备: ${deviceTypes.join('、')}`)
665
+ }
666
+ if (mainAbility !== undefined) {
667
+ lines.push(`主 Ability: ${mainAbility}`)
668
+ }
669
+ if (abilities.length > 1) {
670
+ lines.push(`Abilities: ${abilities.join('、')}`)
671
+ }
672
+ if (minApi !== undefined || targetApi !== undefined) {
673
+ const apiText =
674
+ [
675
+ minApi === undefined ? '' : `兼容 ${minApi}`,
676
+ targetApi === undefined ? '' : `目标 ${targetApi}`,
677
+ ]
678
+ .filter(Boolean)
679
+ .join(' / ') + (releaseType === undefined ? '' : `(${releaseType})`)
680
+ lines.push(`API: ${apiText}`)
681
+ }
682
+ if (deliveryWithInstall !== undefined || installationFree !== undefined) {
683
+ const install = []
684
+ if (deliveryWithInstall === true) {
685
+ install.push('随应用安装')
686
+ } else if (deliveryWithInstall === false) {
687
+ install.push('按需安装')
688
+ }
689
+ if (installationFree === true) {
690
+ install.push('免安装')
691
+ }
692
+ if (install.length > 0) {
693
+ lines.push(`安装: ${install.join(',')}`)
694
+ }
695
+ }
696
+ if (buildMode !== undefined) {
697
+ lines.push(`构建模式: ${buildMode}`)
698
+ }
699
+ if (debug !== undefined) {
700
+ lines.push(`调试: ${debug ? '是' : '否'}`)
701
+ }
702
+ if (permissions.length > 0) {
703
+ lines.push(`权限: ${permissions.join('、')}`)
704
+ }
705
+ if (kind === 'APP') {
706
+ for (const item of innerModules) {
707
+ lines.push(`内层模块: ${item.name}(${formatBytes(item.size)})`)
708
+ }
709
+ if (pac !== null) {
710
+ const dataProcess = values(pac.dataProcess).length
711
+ const specialApis = values(pac.specialAPIs).length
712
+ lines.push(`pac.json: 数据处理 ${dataProcess} 项、特殊 API ${specialApis} 项`)
713
+ }
714
+ }
715
+ addSignatureCertificates(lines, certificateGroups, verbose)
716
+ if (signature !== null && certificateGroups.length === 0) {
717
+ lines.push('', '签名证书: 未解析到证书(仅检测到签名块)')
718
+ }
719
+ if (verbose) {
720
+ if (label !== undefined) {
721
+ lines.push(`应用标签: ${label}`)
722
+ }
723
+ if (vendor !== undefined) {
724
+ lines.push(`厂商: ${vendor}`)
725
+ }
726
+ if (compileSdk !== undefined || compileSdkType !== undefined) {
727
+ const sdk = [compileSdk, compileSdkType === undefined ? '' : `(${compileSdkType})`]
728
+ .filter(Boolean)
729
+ .join('')
730
+ lines.push(`编译 SDK: ${sdk}`)
731
+ }
732
+ if (packageNames.length > 0) {
733
+ lines.push(`模块包: ${packageNames.join('、')}`)
734
+ }
735
+
736
+ lines.push('', '签名块:')
737
+ if (signature === null) {
738
+ lines.push('无')
739
+ } else if (signature.fallback) {
740
+ lines.push(`Code Sign Block 偏移: ${signature.start}`)
741
+ } else {
742
+ lines.push(`版本: ${signature.version}`)
743
+ lines.push(`大小: ${signature.size} 字节`)
744
+ lines.push(`子块数量: ${signature.count}`)
745
+ lines.push(`起始偏移: ${signature.start}`)
746
+ for (const block of signature.blocks) {
747
+ const name = SIGN_BLOCK_TYPES.get(block.type) ?? '未知类型'
748
+ lines.push(`- 0x${block.type.toString(16)} ${name},${block.length} 字节`)
749
+ }
750
+ }
751
+
752
+ lines.push('', 'ZIP 条目:')
753
+ for (const entry of entries) {
754
+ const method = entry.method === 0 ? 'store' : entry.method === 8 ? 'deflate' : `method ${entry.method}`
755
+ lines.push(`- ${entry.name}(${entry.size} 字节,${method})`)
756
+ }
757
+
758
+ if (pack !== null) {
759
+ lines.push('', `pack.info: ${JSON.stringify(pack)}`)
760
+ }
761
+ if (moduleJson !== null) {
762
+ lines.push(`module.json: ${JSON.stringify(moduleJson)}`)
763
+ }
764
+ if (pac !== null) {
765
+ lines.push(`pac.json: ${JSON.stringify(pac)}`)
766
+ }
767
+ }
768
+
769
+ return lines.join('\n')
770
+ }
771
+
772
+ // ------------------------------------------------------------------ 命令入口
773
+
774
+ /** 按 HAP 文件读取 ZIP、pack.info、module.json 与签名块 */
775
+ function parseHap(buffer, path, help) {
776
+ const eocd = readEndOfCentralDirectory(buffer, help)
777
+ const entries = readZipEntries(buffer, eocd, help)
778
+ const pack = readJsonEntry(buffer, entries, 'pack.info', help)
779
+ const moduleJson = readJsonEntry(buffer, entries, 'module.json', help)
780
+ if (pack === null && moduleJson === null) {
781
+ throw new UsageError(
782
+ `不是 HAP:${path} 里没有 pack.info 或 module.json`,
783
+ help(),
784
+ )
785
+ }
786
+ const signature = readHapSignature(buffer, eocd.centralOffset)
787
+ return { entries, pack, moduleJson, signature }
788
+ }
789
+
790
+ /** 按 APP 文件读取外层 ZIP、pack.info、pac.json、内层模块与签名块 */
791
+ function parseApp(buffer, path, help) {
792
+ const eocd = readEndOfCentralDirectory(buffer, help)
793
+ const entries = readZipEntries(buffer, eocd, help)
794
+ const pack = readJsonEntry(buffer, entries, 'pack.info', help)
795
+ const pac = readJsonEntry(buffer, entries, 'pac.json', help)
796
+ if (pack === null && pac === null) {
797
+ throw new UsageError(
798
+ `不是 APP:${path} 里没有 pack.info 或 pac.json`,
799
+ help(),
800
+ )
801
+ }
802
+ const innerModules = entries.filter((entry) => /\.(hap|hsp)$/i.test(entry.name))
803
+ const signature = readHapSignature(buffer, eocd.centralOffset)
804
+ return { entries, pack, pac, innerModules, signature }
805
+ }
806
+
807
+ function readHapFile(path, help) {
808
+ try {
809
+ return readFileSync(path)
810
+ } catch (error) {
811
+ if (error.code === 'ENOENT') {
812
+ throw new UsageError(`文件不存在: ${path}`, help())
813
+ }
814
+ throw new UsageError(`读取文件失败: ${path}: ${error.message}`, help())
815
+ }
816
+ }
817
+
818
+ function runHap(argv, env) {
819
+ if (argv.some((token) => isHelpToken(token))) {
820
+ stdout(renderHapHelp())
821
+ return EXIT_OK
822
+ }
823
+
824
+ const options = parseOptions(
825
+ argv,
826
+ HAP_OPTIONS,
827
+ renderHapHelp,
828
+ HAP_FLAGS,
829
+ HAP_MULTI_OPTIONS,
830
+ )
831
+ const verbose = resolveVerbose(options, env, renderHapHelp)
832
+ const envPath = readEnv(env, ENV_FILE)
833
+ const optionPaths = options.get(OPTION_FILE)
834
+ const paths =
835
+ optionPaths === undefined
836
+ ? envPath === undefined
837
+ ? undefined
838
+ : [envPath]
839
+ : Array.isArray(optionPaths)
840
+ ? optionPaths
841
+ : [optionPaths]
842
+ if (paths === undefined) {
843
+ throw new UsageError(
844
+ `缺少必填参数: ${OPTION_FILE}(HAP 文件路径,可多个),可用环境变量 ${ENV_FILE}`,
845
+ renderHapHelp(),
846
+ )
847
+ }
848
+
849
+ for (const [index, path] of paths.entries()) {
850
+ const buffer = readHapFile(path, renderHapHelp)
851
+ const parsed = parseHap(buffer, path, renderHapHelp)
852
+ const certificateGroups = collectHapSignatureCertificates(parsed.signature, buffer)
853
+ stdout(
854
+ renderHap(
855
+ path,
856
+ buffer.length,
857
+ parsed.entries,
858
+ parsed.pack,
859
+ parsed.moduleJson,
860
+ parsed.signature,
861
+ certificateGroups,
862
+ verbose,
863
+ ),
864
+ )
865
+ if (index < paths.length - 1) {
866
+ stdout('')
867
+ }
868
+ }
869
+ return EXIT_OK
870
+ }
871
+
872
+ function runApp(argv, env) {
873
+ if (argv.some((token) => isHelpToken(token))) {
874
+ stdout(renderAppHelp())
875
+ return EXIT_OK
876
+ }
877
+
878
+ const options = parseOptions(
879
+ argv,
880
+ HAP_OPTIONS,
881
+ renderAppHelp,
882
+ HAP_FLAGS,
883
+ HAP_MULTI_OPTIONS,
884
+ )
885
+ const verbose = resolveVerbose(options, env, renderAppHelp, ENV_APP_VERBOSE)
886
+ const envPath = readEnv(env, ENV_APP_FILE)
887
+ const optionPaths = options.get(OPTION_FILE)
888
+ const paths =
889
+ optionPaths === undefined
890
+ ? envPath === undefined
891
+ ? undefined
892
+ : [envPath]
893
+ : Array.isArray(optionPaths)
894
+ ? optionPaths
895
+ : [optionPaths]
896
+ if (paths === undefined) {
897
+ throw new UsageError(
898
+ `缺少必填参数: ${OPTION_FILE}(APP 文件路径,可多个),可用环境变量 ${ENV_APP_FILE}`,
899
+ renderAppHelp(),
900
+ )
901
+ }
902
+
903
+ for (const [index, path] of paths.entries()) {
904
+ const buffer = readHapFile(path, renderAppHelp)
905
+ const parsed = parseApp(buffer, path, renderAppHelp)
906
+ const certificateGroups = collectHapSignatureCertificates(parsed.signature, buffer)
907
+ stdout(
908
+ renderHap(
909
+ path,
910
+ buffer.length,
911
+ parsed.entries,
912
+ parsed.pack,
913
+ null,
914
+ parsed.signature,
915
+ certificateGroups,
916
+ verbose,
917
+ { kind: 'APP', pac: parsed.pac, innerModules: parsed.innerModules },
918
+ ),
919
+ )
920
+ if (index < paths.length - 1) {
921
+ stdout('')
922
+ }
923
+ }
924
+ return EXIT_OK
925
+ }
926
+
927
+ /** app 命令描述,由 publish-apk.js 注册到根命令表 */
928
+ export const appCommand = {
929
+ name: 'app',
930
+ summary: '查看 APP 安装包信息(包名 / 版本 / 内层模块 / 签名)',
931
+ run: runApp,
932
+ }
933
+
934
+ /** hap 命令描述,由 publish-apk.js 注册到根命令表 */
935
+ export const hapCommand = {
936
+ name: COMMAND_NAME,
937
+ summary: '查看 HAP 安装包信息(包名 / 版本 / API / 签名)',
938
+ run: runHap,
939
+ }