HarmonyOS Node.js fs:三方库资源路径大小写碰撞预检【鸿蒙心迹】
同一个资源目录里如果同时出现icons/Share.png和icons/share.png,代码审查时两者看着只是首字母不同,到了跨系统协作和持续集成阶段,却可能成为两份互相覆盖的文件。这里先不讨论HarmonyOS设备文件系统是否区分大小写,因为具体行为要以运行环境为准;真正需要控制的是交付链路的可移植性。仓库、构建机器、解压工具和资源引用字符串不一定采用完全一致的路径规则,提前发现碰撞,比等到同事换一台机器后排查省心得多。
我们给一个引入三方HAR资源的工程设计了离线检查器 CaseLens,任务编号 HAR-2303。它不编译HAR,不修改包内容,也不冒充官方应用市场检测能力,只扫描某个准备交付的资源根目录。示例目录有四个文件,其中两张图仅文件名大小写不同。预期报告为FILES 4、COLLISION_GROUPS 1、BLOCKED。这更像一次可重复的构建前体检,而不是运行时崩溃后的补救。

一、为什么碰撞不等于简单的重名
一般的Map使用字符串原样作为键;Share.png和share.png自然是两个不同键。但构建资源时,文件从压缩包进入工作目录、从工作目录进入制品,可能经过不同平台和工具。仅在当前机器上执行一次existsSync,不能说明另一个环境也能无损保存这两个名字。路径大小写归一化是为了发现潜在歧义,并不表示HarmonyOS框架会自动将文件名转为小写。
本方案的比较键选择:先把路径分隔符变成正斜杠,再做Unicode NFC规范化,最后调用toLowerCase()。这个规则覆盖一部分常见大小写和组合字符差异,但它不是完整的跨平台文件名等价性判定。尤其toLowerCase()不等同于完整Unicode case-folding,土耳其语字符、兼容字符和某些特殊符号要结合实际交付平台增加测试。如果团队需要精确复现某个文件系统,应该采用针对该系统的专用规则,别把这里的预检宣称为全球通用结论。
华为开发者文档说明HAR属于静态共享包,可以携带代码、资源和配置,并可通过ohpm或本地形式引用。这里的扫描对象是准备纳入工程的三方库资源源码目录,而不是随意猜测.har压缩包的内部结构。只有在资源合并规则明确的地方才执行门禁,既能守住边界,又不会误伤其他用途的大小写敏感数据。
二、确定扫描根和归一化规则
先用Node.js标准库完成目录定位。这里选用Node.js而非ArkTS,原因是检查必须发生在构建机上,手机App不应该持有仓库遍历权限。将脚本保存为case-lens.mjs,由CI或开发者终端调用。脚本只接受一个目录参数,不要求安装额外npm依赖,环境差异主要来自Node.js版本、操作系统以及扫描目录本身。
第一段代码定义根目录、扫描结果容器和比较键。为了避免输出被平台分隔符左右,报告统一采用/形式。把路径当字符串展示可以,但后续读取文件仍必须使用path模块组合真实绝对路径。
import { readdir, lstat } from 'node:fs/promises';
import { resolve, join, relative, sep } from 'node:path';
const root = resolve(process.argv[2] ?? 'vendor/har-assets');
const groups = new Map();
const skipped = [];
let fileCount = 0;
function normalizedKey(rel) {
return rel.split(sep).join('/').normalize('NFC').toLowerCase();
}
root保存经过resolve整理的绝对位置,groups的键是归一化路径,值是原始相对路径数组。skipped用于记录无法直接当普通文件处理的条目,不应该悄悄丢弃;fileCount只统计真实枚举到的普通文件。整个检查器不向根目录写入状态,运行结束不需要恢复源文件。注意这里的Map、normalize属于JavaScript和Node标准能力,绝不是所谓HarmonyOS三方包专属扫描API。
三、递归遍历要尊重链接和错误
扫描时需要把子目录也纳入键,否则不同路径层级出现相同文件名会被误判。只有相对路径整体归一化后相同,才构成同组碰撞。更微妙的问题是符号链接:它可能指向目录外侧,若跟随链接递归,扫描范围就脱离了用户指定的仓库。示例选择遇到符号链接便记为跳过项,不解析目标;需要扫描链接内容的工程必须额外声明可访问范围和循环检测策略。
第二段代码采用readdir和lstat。目录不可读会让Promise抛出异常并由最外层捕获,最终返回非零退出码。忽略这种错误后再打印绿色通过,才是预检中最危险的假象。对路径归属的检查只是防御性补充,主要边界仍然是脚本不主动跟随链接。
async function walk(dir) {
const items = await readdir(dir, { withFileTypes: true });
for (const item of items) {
const full = join(dir, item.name);
const info = await lstat(full);
const rel = relative(root, full).split(sep).join('/');
if (rel === '..' || rel.startsWith('../')) {
throw new Error(`PATH_ESCAPE: ${rel}`);
}
if (info.isSymbolicLink()) {
skipped.push(rel);
continue;
}
if (info.isDirectory()) {
await walk(full);
continue;
}
if (!info.isFile()) {
skipped.push(rel);
continue;
}
fileCount += 1;
const key = normalizedKey(rel);
const bucket = groups.get(key) ?? [];
bucket.push(rel);
groups.set(key, bucket);
}
}
item.name来自实际目录项,lstat让脚本不跟随符号链接拿到外部文件信息。递归调用结束后才汇总,因此即使目录有多层,最终报告仍可定位到完整相对路径。无特殊需要时不要并发启动无限数量的目录读取,资源树很大时应添加并发上限、超时和目录数预算。本例保持串行,优先保证可预测的错误信息。文件内容不会被读入内存,也不会创建打开但未关闭的流,单次执行完成后仅保留少量路径字符串。
四、报告需要能阻断错误交付
发现碰撞但CI退出码仍为零,实际意义就不大。CaseLens规定:存在任何路径碰撞、出现扫描失败,或有团队不允许的特殊文件类型时,构建门禁必须进入非零状态。本文示例对符号链接选择列出SKIPPED,是否视为阻断由项目策略决定;在供应链风险较高的交付链中,可以把跳过本身也纳入失败条件。策略写清楚比把所有问题都笼统叫作“资源异常”更重要。
第三段代码打印稳定结果。为了让不同操作系统的报告顺序尽量一致,先排序输出;即便扫描顺序变化,冲突内容仍是同一组。脚本入口捕获异常,避免发生权限问题后还显示成功提示。process.exitCode允许现有日志输出完成,不需要调用立即退出的process.exit()。
async function main() {
await walk(root);
const collisions = [...groups.entries()]
.filter(([, paths]) => paths.length > 1)
.sort(([a], [b]) => a.localeCompare(b));
console.log(`HAR-2303 FILES ${fileCount}`);
console.log(`COLLISION_GROUPS ${collisions.length}`);
for (const [, paths] of collisions) {
console.log(`CASE_COLLISION: ${paths.sort().join(' <> ')}`);
}
console.log(`SKIPPED ${skipped.length}`);
const blocked = collisions.length > 0;
console.log(blocked ? 'BLOCKED' : 'PASS');
process.exitCode = blocked ? 1 : 0;
}
main().catch((error) => {
console.error('SCAN_FAILED:', String(error));
process.exitCode = 2;
});
这段代码没有依赖模拟器生命周期,输入是本地资源目录,输出是CI标准输出与退出码。脚本执行完成后,Node进程自行退出;若需要把结果接进持续集成平台,可再写一层JSON序列化,不建议依靠正则解析人类可读日志。如果扫描失败,退出码2与“资源确实存在碰撞”的退出码1应分开,让流水线知道该重试环境,还是该修复资源命名。

五、把四个文件走一遍
假设输入目录包含icons/Share.png、icons/share.png、palette/dark.json和notes/README.txt。文件数量是4,归一化后前两条同为icons/share.png,形成一个碰撞组。第三条、第四条没有相同的归一化路径,所以只输出一个CASE_COLLISION,不是两个错误组。预期CLI日志为HAR-2303 FILES 4、COLLISION_GROUPS 1、CASE_COLLISION: icons/Share.png <> icons/share.png、SKIPPED 0和BLOCKED。
合成手机画面把这份结果做成可读的质量面板:上方是CaseLens / HAR-2303,中间写4 FILES与1 COLLISION,下方专门展示发生碰撞的两条路径。手机只是报告预览的视觉设计,并不表示前面的Node.js脚本直接在手机端执行,更不能将它当成真实截图证明。IDE合成图右侧预览同一份数据,底部日志与脚本字段对应,帮助读者检查文章、代码和图片有没有出现版本漂移。

六、容易混淆的边界情况
如果两个文件分别是icon/Share.png与icons/share.png,路径层级不同,不属于同一组。如果一个是Share.PNG另一个是share.png,当前规则会报告潜在碰撞,尽管某些构建器可能把后缀当成不同资源类型处理。这个偏保守的选择符合“提前发现不确定性”的目标,但项目负责人有权制定不同的白名单。白名单需要精确到路径、原因和过期时间,不能因为第一次出现误报就永久关闭检测。
含有组合字符的文件名还会经过NFC。假如一组目录来自不同的操作系统,一份名称使用预组字符,另一份使用基字符加组合符,肉眼可能完全一致。预检把它们归入同一候选组,是为了提醒人工复核,而不是宣布它们一定会在所有平台上互相覆盖。处理复杂文字时要把原始文件名原样写入报告,这比只写归一化键更利于回溯问题。
更大的工程边界在于本脚本不检查HAR产物里的资源索引,也不验证$r()引用是否存在。资源路径改名后,调用代码和样式清单还需要一同更新。脚本扫描通过只代表指定目录里没有触发本规则的冲突,不能代替ohpm install、编译、签名或上架审核。尤其不要据此推断华为官方有“大小写碰撞不得上架”的特定政策;这里的BLOCKED完全是团队自定义质量门禁。
七、怎样纳入实际工作流
推荐在代码合并前和生成HAR前各运行一次,前者拦截无意间引入的双文件名,后者覆盖自动生成的资源。首次运行可以只打印报告,待历史问题清理后再将退出码接入构建失败条件。由于文件系统本身可能不允许两个冲突文件共存,跨平台检验最好在能保留原始文件名的构建环境中进行,并用仓库索引或归档清单作为补充证据。
华为关于HAR发布、依赖配置和模块化编译的官方资料,主要用于核实HAR在工程中的定位与交付方式。遍历、路径归一化以及退出码是Node.js脚本的通用能力,而非HarmonyOS系统API。技术结论非常具体:不要等到资源被消费时才猜测大小写问题,给三方库资源目录建立一次可重复、有证据、可追踪的预检;同时承认它覆盖的是本项目定义的风险范围,没覆盖的交给构建系统和正式测试继续验证。
参考资料:华为HAR共享包发布指南;HarmonyOS模块化编译;依赖项配置。
更多推荐



所有评论(0)