ohpm+Node.js:三方包许可证原文缺失交付清单核验【鸿蒙心迹】
一次看起来顺利的依赖安装,并不等于最终交付包里的开源声明已经整理完毕。HarmonyOS 工程里的 oh-package.json5 有 license 字段,但“声明了 MIT”与“团队手上有可复核的许可证原文及来源”是两件事。尤其当依赖来自私仓、本地 HAR 或经过二次封装的包时,维护者可能只记住包名与版本,发布时却找不到适用的声明文件。我想解决的不是让脚本自动判断法律合规,而是让交付人员在打包前看见证据缺口:哪一个包声明了什么许可证、原文来自哪一份文件、这份文件当前的哈希是多少。如果找不到,构建门禁返回待核对,不把未知内容悄悄当成许可通过。
## 一、先画一条工具不能跨越的线本篇 Demo 名为 LicenseLedger,脚本入口 tools/license-check.mjs,演示任务 LL-1815。它属于发布前的本地静态检查,不属于 HarmonyOS 系统权限接口。华为的 ohpm 文档明确给出了模块级 oh-package.json5 中 license 字段及生产、开发、动态依赖的区分;同时提醒开发者不要把锁文件当成供外部工具任意解析的稳定接口。因此这版不去读取 oh-package-lock.json5 内部结构。输入是一份由工程维护者整理的明确目标路径清单,而不是宣称自动遍历全部传递依赖。示例用三个虚构包目录:@demo/palette-ui、@demo/format-core 和 @demo/depth-helper。前两个目录分别拥有非空 LICENSE 文件且声明 MIT、Apache-2.0;第三个目录虽有包描述,却没有可提供的许可证原文。样例审查结果应为 2 PASS / 1 HOLD,总状态 HOLD_LICENSE。这里对“通过”的含义要说得窄:脚本确认元数据字段存在、原文文件非空、路径位于允许的目录中,并计算哈希;它没有确认这份原文是否足以满足具体许可证义务,也不能证明该文件确实来自上游发布方。合规结论仍然属于人工审核与法务判断。## 二、输入名单不要让脚本自己猜首先把本轮打算纳入交付的目标写清楚。下面是 tools/license-targets.mjs 的示例。目录取相对于工程根目录的路径,正式项目应根据真实安装产物或仓库归档位置维护它。oh_modules 的实际目录组织会因依赖类型和工具行为而不同,不能仅凭包名拼接目录就声称找到了安装包。js// tools/license-targets.mjs:仅用于说明输入协议,均为虚构包export const targetList = [ { name: '@demo/palette-ui', dir: 'audit-input/palette-ui' }, { name: '@demo/format-core', dir: 'audit-input/format-core' }, { name: '@demo/depth-helper', dir: 'audit-input/depth-helper' }];export const taskId = 'LL-1815';这样做看似笨一点,却避免了一个危险假设:把 dependencies 列表当成最终制品里所有使用到的开源组件。有些构建依赖不会进入用户设备,有些间接依赖或二进制文件又可能需要额外的许可证说明。清单维护者应将包管理器提供的依赖列表、打包产物和手工引入的二进制文件交叉核对,无法自动发现的内容需要单独登记。从操作体验考虑,每条输入还可以增加 owner、sourceUrl 和“对应产物”的人工证据字段,但这些不能被脚本默认填成“已确认”。本文只保留包名和目录,降低示例的扩展成本。后续添加字段要有明确的信任来源,而不是让空字符串也出现在绿色结果里。## 三、许可证字段不能代替许可证原文关键实现读取模块目录里的 oh-package.json5,用 JSON5 解析器处理注释、尾逗号等格式;在这个独立 Node ESM 检查脚本所在的工具目录安装 json5(例如 npm i -D json5),这里使用它的 parse,不是 HarmonyOS 内置 API。随后寻找常见的 LICENSE、LICENSE.md、COPYING 文件,检查非空并计算 SHA-256。假设扫描输入已经经过项目层面的目录授权,本段仍要阻挡 ../ 路径越界。js// tools/license-check.mjs(核心函数)import { readFile, stat } from 'node:fs/promises';import { resolve, join, sep } from 'node:path';import { createHash } from 'node:crypto';import JSON5 from 'json5';const root = resolve(process.cwd());async function inspectOne(item) { const dir = resolve(root, item.dir); if (dir !== root && !dir.startsWith(root + sep)) return { name: item.name, status: 'HOLD', reason: 'PATH_ESCAPE' }; try { const raw = await readFile(join(dir, 'oh-package.json5'), 'utf8'); const meta = JSON5.parse(raw); if (meta.name !== item.name || typeof meta.license !== 'string' || meta.license.trim() === '') return { name: item.name, status: 'HOLD', reason: 'META_MISMATCH' }; for (const file of ['LICENSE', 'LICENSE.md', 'COPYING']) { try { const location = join(dir, file); if (!(await stat(location)).isFile()) continue; const body = await readFile(location); if (body.length === 0) continue; return { name: item.name, license: meta.license, file, sha256: createHash('sha256').update(body).digest('hex'), status: 'PASS' }; } catch { /* 继续查找该目录的其他候选文件 */ } } return { name: item.name, license: meta.license, status: 'HOLD', reason: 'LICENSE_TEXT_MISSING' }; } catch { return { name: item.name, status: 'HOLD', reason: 'READ_OR_PARSE_FAILED' }; }}这里的状态拆分有实际意义。META_MISMATCH 表示扫描到的包描述并不是预期包;LICENSE_TEXT_MISSING 表示找得到声明,却没有找到可读取的原文;READ_OR_PARSE_FAILED 可能来自文件不存在、JSON5 格式错误或读权限问题。与其简单打印“失败”,不如保留不同的处理路径,避免操作人员一遍遍重装依赖却没有触及真实原因。上面这个简化函数还不是防恶意文件系统的安全沙箱:符号链接可能跨越目录边界,二进制伪装的 LICENSE 也可能是无意义内容。正式门禁要结合 realpath 对目标目录和候选文件做二次约束,并让人审查实际原文。sha256 证明的是文件字节在当前检查时刻的标识,不是证明版权归属、授权有效或交付已经满足开源要求。
## 四、输出应该可追踪,也应该敢于失败工具最后汇总结果,写入机器可读报告,并将存在待核条目时的退出码设置为非零,供团队流水线自行决定是否阻断。结果排序可以避免同一批输入顺序变化导致不必要的文本差异。示例数据的重点不是颜色,而是人能从 HOLD_LICENSE 回到一个具体目录处理缺失项。js// 与前两段放在同一Node ESM工程中import { writeFile } from 'node:fs/promises';import { targetList, taskId } from './license-targets.mjs';const checked = [];for (const item of targetList) checked.push(await inspectOne(item));checked.sort((a, b) => a.name.localeCompare(b.name));const passed = checked.filter((r) => r.status === 'PASS').length;const held = checked.length - passed;const report = { taskId, state: held ? 'HOLD_LICENSE' : 'READY_FOR_REVIEW', passed, held, entries: checked };await writeFile('license-report.json', JSON.stringify(report, null, 2));console.info(`[LicenseLedger] ${taskId} pass=${passed} hold=${held}`);console.info(`[LicenseLedger] ${taskId} state=${report.state}`);if (held) process.exitCode = 2;注意这里即使三条全部通过,状态也只是 READY_FOR_REVIEW,不叫“法律合规完成”或“平台审核必过”。正式系统应把许可证归档与应用商店审核材料分开:前者是内部证据管理,后者仍需按实际平台规则提交。脚本不会访问 AppGallery Connect,也不会替应用发起审核。样例中第三个包不提供原文,因此构造的报告显示 PASS 2、HOLD 1、HOLD_LICENSE。HiLog 画面出现的 [LicenseLedger] LL-1815 pass=2 hold=1 和 [LicenseLedger] LL-1815 state=HOLD_LICENSE 只是为了把流程画清楚;真正执行上述 Node 脚本时日志会写在终端,并不会天然出现在 HarmonyOS 的 HiLog 中。开发截图里的 HiLog 区域是与终端检查信息相对应的演示日志排版。## 五、把报告拿给交付人员看我给示意页面设置了三行包检查结果:@demo/palette-ui 为 PASS,@demo/format-core 为 PASS,@demo/depth-helper 为 HOLD,并突出显示 LICENSE_TEXT_MISSING。右侧模拟器中的“查看缺失项”按钮代表打开第三条的详情,不代表脚本自动下载许可证。这个设计的价值是明确让下一个处理人知道:应从哪里找原文件、找谁确认,而不是让他从一行“打包失败”里猜。
如果负责工程发布,我会把许可证检查安排在打包前的单独阶段,并在报告中记录执行者、目标清单快照、构建提交标识和每份原文文件哈希。目标目录有变化时重跑;升级依赖、替换 HAR、打入原生 .so 时也重新核对。检查完成之后,报告要和具体制品绑定,而不是作为一个永不过期的“历史合格证明”。边界也需要明确。缺少许可证原文并不自动意味着某个库不能使用;有原文也不意味着采用它的方式一定符合要求。许可证中的附加通知、专利条款、源代码提供义务等需要具体判断,脚本不能用字符串匹配给法律结论。真正的准入策略应由组织根据许可证、分发方式和产品场景制定,技术门禁负责把无法判断的情况暴露出来。针对扫描失败的错误处理也应该有“可重试”和“需要人工”之分。权限不足、文件读取中断可以在修好环境后重新运行;包名与预期不一致则意味着清单可能指向了错误的产物,不能通过创建一个空 LICENSE 来消除错误。运维面板可以保留首次发现时间、最后核对人和修复说明,但不要把这类信息混进许可证哈希本身:后者应只描述文件原文。在跨平台团队里,尤其要避免把工具生成的报告当作源码仓库里不可修改的事实。业务侧可以对 license-report.json 做归档,发布流水线保存一份只读副本;而下一次构建还应重新扫描,防止同名目录被替换。对于通过包管理器下载的依赖,可以记录取得原文的上游链接与获取日期,待人工确认后再纳入正式通知清单。这些步骤是证据链设计,不是法律判断自动化。## 六、工程上能确定的结论这里能复现的是一个受控目录的本地检查流程:给三个虚构目标和两份有效示例文件,第三个缺失原文时,计算逻辑应输出 2 PASS / 1 HOLD。尚未在用户工程安装依赖,也没有运行 DevEco、实际 ohpm 安装或上架审核。即使本地脚本的函数能通过 Node 测试,也不能将其等同于平台审查通过。更重要的工程判断是:不要为了自动化而假定未知结构稳定。平台文档不建议直接解析 oh-package-lock.json5,我们便保留一份人为确认的目标清单,用可解释的静态核验覆盖确定的目录;覆盖不到的部分显式留下待审。这比生成一张“全部绿色”的报告更有交付价值。参考资料:华为 oh-package.json5 官方配置说明(页面显示更新于 2026-10-08);华为 ohpm install 官方说明;JSON5 官方解析器说明。所有包名和结果均为构造示例,不主张任何真实项目已通过检测。
更多推荐





所有评论(0)