为什么同一份资源在不同环境里看起来不一样

我整理一个准备打成 HAR 的本地三方组件库时,注意到两名同事提交了两套看上去没有问题的图标。开发环境里都能打开:一个叫 Home.png,另一个叫 home.png。还有两份多语言说明文件,在文件管理器里看上去都显示为带重音符号的同一个单词。仓库本身可以保留它们,但等文件在不同系统、压缩工具和构建流程间周转,就可能出现覆盖、漏拷、路径引用失效或审核记录难以核对的问题。

我想处理的是团队自己的交付合同:一个待打包的本地资源目录里,如果有两个路径只差字母大小写或者 Unicode 组合方式,就先要求人工确认,再发布共享包。与此前许可证材料缺失的检查不同,这次扫描的是资源文件路径是否存在潜在歧义,既不判断依赖许可证,也不读取或修改 oh-package-lock.json5 的内部结构。

把风险具体化成五个文件

Demo 叫 CasePathGate,任务编号 CP-401。为了让检查可以被其他工程师复核,我没有拿公司真实组件库开刀,而是放入五个小型构造文件:R-01 对应 icons/Home.png,R-02 对应 icons/home.png,R-03 对应 banners/hero.png,R-04 对应 i18n/é.txt,R-05 对应 i18n/e 加组合重音的文本文件。最后两项在视觉上十分接近,底层 Unicode 序列却不一样。

按照录入顺序,R-01、R-03、R-04 可以进入清单;R-02 与先出现的文件发生 CASE 冲突,R-05 与已经接受的文本文件发生 UNICODE 冲突。最终目标是 PASS 3 / HOLD 2。这里的 PASS 代表“目前没有与此前记录发生规范化同名”,绝不代表整个三方库符合发布要求。检查结果先落盘成为待复核清单,审阅者可以选择改名、统一资源引用或明确排除某项文件。

官方 oh-package.json5 说明了模块级包的名称、版本、入口和依赖等信息,HAR 也是可以通过工具链管理和发布的共享包形式。我的检查放在本地 HAR 资源交接环节,目的是尽量在打包以前发现冲突。文件名比较规则来自项目工程经验。

为什么不能简单调用 toLowerCase 就完事

只把路径变成小写,可以找到 Home 与 home,却未必能找到视觉相同的 Unicode 名称。字符 é 可以由单个预组字符组成,也能由字母 e 加组合重音组成。某些系统和编辑器会替我们做规范化,某些则保留原始编码。项目发布产物经过不同程序处理时,这种差异会让资源映射关系产生歧义。

我把规则限定为两步:先将 Windows 斜杠统一为斜杠并做 Unicode NFC 规范化,再进行小写化生成对比键。对比键只用于找可能冲突,不会直接重写真实文件名。扫描严格按照构造清单的既定顺序,而不是依赖操作系统枚举顺序。这样哪个文件被保留、哪个文件需要人工复核,才能稳定复现。对于特殊语言的大小写映射,项目还可以扩展专门的策略;这版只验证一个小范围的示例合同。

export function auditNames(manifest) {
  const seen = new Map();
  return manifest.map(({ id, path: rel }) => {
    const slashPath = String(rel).replaceAll('\\', '/');
    const key = slashPath.normalize('NFC').toLowerCase();
    const previous = seen.get(key);
    if (previous !== undefined) {
      const sameNfc = previous.normalize('NFC') === slashPath.normalize('NFC');
      return { id, path: rel, status: 'HOLD',
        reason: sameNfc ? 'UNICODE' : 'CASE', conflictsWith: previous };
    }
    seen.set(key, slashPath);
    return { id, path: rel, status: 'PASS', reason: 'UNIQUE' };
  });
}

函数有意返回独立的业务状态,不接触外部真实文件,也没有引入三方依赖。这样做便于单元测试:给相同清单和相同顺序,总能得出相同结论。需要注意的是,假设某条记录既包含大小写变化又包含 Unicode 差异,这个简化分类可能只能给出一个主要原因,因此在真实项目里还应该把两个文件的原始码位序列同时写进诊断报告。

让报告能够对应实际的素材文件

只检查 JSON 里几条字符串很容易得到漂亮的统计,却不能保证清单中的素材真正存在。我在正式脚本里又增加了路径解析和文件存在性检查:以示例目录 fixtures/local-har-assets 为根,解析相对路径并判断是否越出根目录,再检查目标是否为普通文件。若是目录、缺失项或明显越界的路径,就转成 HOLD。完整工具可在随包的 tools/case-audit.mjs 中运行,并将结构化结果写到 reports/names-report.json。

import fs from 'node:fs';
import path from 'node:path';
const base = '/demo/fixtures/local-har-assets'; // 实际脚本从 import.meta.url 推导目录
function isUsable(rel) {
  const full = path.resolve(base, rel);
  const within = path.relative(base, full);
  return !within.startsWith('..') && !path.isAbsolute(within) &&
    fs.existsSync(full) && fs.lstatSync(full).isFile();
}

这里展示的是路径判断的基本思路,不要把代码块里的 /demo 路径原样照搬。正式可执行版不用写死机器上的绝对路径。通过 lstat 拒绝目录与普通符号链接,是示例的一道防线;若要审计攻击者可控的路径,还需要处理符号链接递归、竞态条件、权限错误、路径保留字符以及压缩包解压过程。本文目标是跨平台名字冲突,不把这套小脚本包装成完整的文件安全沙箱。

结果怎样进入共享包发布流程

我把资源条目用 R-01 到 R-05 标识,而不把文件名当作唯一主键。理由很简单:产生冲突的时候,两个文件的名字可能恰好是我们正在争议的内容。如果以归一化后的文件名作为业务 ID,报告里就很难同时表达“前一个被接受”和“后一个被阻断”。固定 ID 让审核人可以依据原始路径定位文件,也便于在改名之后保留问题的处理历史。

终端日志以 CP-401 过滤,依次出现 PASS、HOLD CASE、PASS、PASS、HOLD UNICODE,最后输出 PASS 3 / HOLD 2。手机图用红色或橙色只标出两条需要复核的记录,绿色表示本地合同检查通过。DevEco Studio 风格界面只是帮助阅读者理解项目目录、脚本、日志和报告的关系,不应把一张示意图说成真正编译完成的 HAR 或通过了应用市场审核。

对于 CI,我建议只让脚本给出机器可读 JSON,不让它自动重命名文件。原因在于路径变动很可能需要同步修改 ArkTS 导入、资源映射、文档引用和单元测试。自动把所有文件转成小写虽然能使扫描表面通过,但很容易留下不可见的引用断链。最好由组件维护者确认真正保留哪个文件,再对照 HAR 的最终产物进行二次检查。

用测试把两种冲突拆开

测试并不需要先装完整的 HarmonyOS SDK。用 Node.js 自带的 assert/strict 即可对纯函数做确定性断言。我关心的不只是两个 HOLD,而是原因是否分别命中 CASE 与 UNICODE。一个误用 lowerCase 的实现可能识别第一个,却漏掉第二个;一个不区分来源的正则也可能把所有相似名称都标成错误,破坏诊断质量。

import assert from 'node:assert/strict';
import { auditNames } from '../tools/case-audit.mjs';
const rows = auditNames([
  { id: 'R-01', path: 'icons/Home.png' },
  { id: 'R-02', path: 'icons/home.png' },
  { id: 'R-04', path: 'i18n/é.txt' },
  { id: 'R-05', path: 'i18n/e\u0301.txt' }
]);
assert.equal(rows[1].reason, 'CASE');
assert.equal(rows[3].reason, 'UNICODE');

随包的 tests/case.test.mjs 另外校验了全量五条记录的通过顺序。此次确实使用 Node.js 运行了构造素材检查与断言,结果达到 PASS 3 / HOLD 2。上线前应在团队实际使用的操作系统、DevEco Studio 与构建环境上补跑一次交叉验证。

生命周期、失败处理和实际工程边界

离线脚本单次运行后即退出,没有持续订阅、后台定时器和 UI 组件生命周期。但输入可能在扫描期间被其他进程修改,因此扫描结果只能表示某一时刻的样本状态。大型仓库中更可靠的做法,是从固定版本的工作区或 CI 工作树生成清单,并在同一提交版本上做打包;报告中附提交编号和源目录快照标识,可以减少“检查过的不是后来发布的那一份”这种争议。

如果资产清单不是数组、相对路径为空、编码异常或者报告目录不可写,工具应明确标记为执行故障,不能借用 HOLD 这种业务拒绝状态。针对 Unicode 的显示问题,可以在完整报告里增加 JSON 转义形式和十六进制码位,帮助远程审阅者把肉眼看起来一样的两个文件分辨开。遇到大小写歧义时,也应记录原始路径,不应只展示规范化后的键值。

这套门禁不需要读取 oh-package-lock.json5。华为官方文档提示不建议使用其他分析工具直接读取锁文件内部内容,因此我刻意避开它。需要核对实际依赖版本,应该走 ohpm 的受支持命令或官方配置流程;需要核对本地待发布资源的命名风险,则直接扫描自己维护的源文件清单。两个问题分开处理,减少工具与依赖管理器之间的不必要耦合。

参考资料与收尾

华为官方:oh-package.json5 配置和共享包说明、ohpm update 命令。官方资料用于理解依赖声明及包管理边界;NFC 和大小写路径合并是本文项目自定义策略。我的最终建议是:在资源进入共享包产物之前拦住名字歧义,保留原始路径与冲突对象,把重命名留给理解业务引用关系的人。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐