鸿蒙 PC Markdown 编辑器存储安全:AtomicFile 原子提交与故障注入 鸿蒙 PC Markdown 编辑器存储安全AtomicFile 原子提交与故障注入恢复文件本来用于保护用户内容如果它自己在进程终止时只写入半截 JSON下一次启动就无法解析如果应用覆盖用户文件失败却没有旧版本安全保存反而扩大损失。存储可靠性的关键不是“调用写入 API没有抛错”而是为每笔状态建立提交边界、回滚路径和可验证结果。本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown分析 HarmonyOS Core File KitAtomicFile的正确使用、流结束与提交顺序、字节长度校验以及用户文件写入前的沙箱备份和故障注入。完整代码位于 https://gitcode.com/VON-/codex_md_oh对应提交3a9146e。两类写入使用不同策略OhMarkdown 有两种重要存储目标。应用沙箱内的恢复 JSON由应用完全控制路径可以使用AtomicFile。用户选择器返回的外部 URI由系统授权当前使用fileIo.open/write/truncate/fsync并在覆盖前把旧文件完整保存到沙箱备份。不能假设同一个 API适用于所有 URI。AtomicFile适合应用私有记录用户文件的 provider、权限和 URI语义可能不同。可靠设计先区分所有权再选择提交方式。原子写入解决什么普通覆盖流程可能先截断旧文件再写新内容。若进程在中间退出旧内容已丢新内容不完整。AtomicFile通常把新数据写到临时位置finishWrite时再替换目标failWrite放弃临时结果并保留上一次完整版本。原子性保证目标文件在旧版本和新版本之间切换不保证内容业务上有效也不保证所有底层设备故障都可恢复。因此实现仍要在写前校验记录、写后校验长度、读时再次验证。先计算 UTF-8 预期字节字符串长度不是落盘字节数。中文、emoji和部分符号使用多个 UTF-8字节。写入函数先计算constexpectedBytesbuffer.from(payload,utf-8).length;后续 stat比较必须使用这个值不能用payload.length。例如一个中文字符 JavaScript长度通常为1UTF-8却为3字节emoji还涉及 UTF-16代理对。用字符数比较文件大小会误报或漏报短写。正确顺序是 end、finish、stat核心实现如下asyncfunctionwriteAtomicPayload(path:string,payload:string):Promisevoid{constexpectedBytesbuffer.from(payload,utf-8).length;constatomicFilenewfileIo.AtomicFile(path);letwriteStarted:booleanfalse;try{constwriteStreamatomicFile.startWrite();writeStartedtrue;awaitnewPromisevoid((resolve,reject){writeStream.end(payload,utf-8,(error?:Error){if(error){reject(error);}else{resolve();}});});atomicFile.finishWrite();writeStartedfalse;constpersistedStatawaitfileIo.stat(path);if(persistedStat.size!expectedBytes){thrownewError(The atomic record expected${expectedBytes}bytesbut persisted${persistedStat.size}.);}}catch(error){if(writeStarted){try{atomicFile.failWrite();}catch(_){}}throwerrorinstanceofError?error:newError(String(error));}}startWrite取得写流并进入事务。writeStream.end不仅写 payload还关闭流Promise等待回调确认完成。之后才能finishWrite提交。提交完成后 stat最终路径并比较字节。曾经使用writeStream.write后直接 finish。write回调完成不一定等于流完全结束底层缓冲与句柄生命周期可能尚未收口。改为 end明确表达“这是最后一段数据”修复了设备测试中的完整性问题。writeStarted 是回滚状态writeStarted只在 startWrite成功后为 truefinish成功后立即变 false。catch中只有事务仍进行时调用 failWrite。如果 startWrite本身抛错没有临时事务可回滚如果 finish已经提交后续 stat发现长度异常此时无法再把已提交事务当未提交回滚错误继续向上传播。状态变量让回滚动作对应真实生命周期而不是在所有异常上盲调 failWrite。failWrite本身也可能失败所以嵌套 try/catch不覆盖原始错误。错误报告应保留最初写入原因而不是被清理异常替换。写后长度校验不是内容校验stat大小相等可以发现零字节、短写和编码长度不符但无法发现同长度内容损坏。恢复 JSON在下次读取时还会 JSON.parse并验证字段测试可以在关键记录写后立即读回比较哈希代价是额外 I/O。当前恢复记录小于五兆写后长度与启动解析形成两级检查。若设备可靠性数据暴露静默损坏应增加读回哈希。不要把finishWrite返回无异常当成端到端证明。原子记录写入前先校验恢复记录只有满足版本、URI、名称、正文大小、BOM、换行、revision和时间约束才写exportasyncfunctionsaveRecoveryRecord(filesDir:string,record:RecoveryRecord):Promisevoid{if(!isRecoveryRecordValid(record)){thrownewError(The recovery record is invalid or exceeds the 5 MiB Alpha limit.);}constpayloadJSON.stringify(record);awaitensureRecoveryDirectory(filesDir);awaitwriteAtomicPayload(getRecoveryPath(filesDir),payload);}原子提交只能保证“完整写入这串字节”不能判断这串字节是否值得恢复。写前业务校验和原子文件语义各自解决不同问题。目录通过mkdir(..., true)按需创建。路径全部位于context.filesDir/recovery不向公共 Documents泄露草稿也不要求额外用户授权。读取端防御损坏和超大文件读取恢复记录先 access再 statconststatawaitfileIo.stat(recoveryPath);if(stat.size0||stat.sizeMAX_RECOVERY_FILE_BYTES){returnundefined;}try{constrecordJSON.parse(awaitfileIo.readText(recoveryPath,{encoding:utf-8}))asRecoveryRecord;returnisRecoveryRecordValid(record)?record:undefined;}catch(_){returnundefined;}空文件、过大文件、非法 UTF-8/JSON和字段不合法都被忽略不让应用启动失败。恢复文件是辅助数据损坏时最安全行为是继续启动并保留错误可观测信息而不是崩溃循环。文件字节上限高于正文字数上限因为 JSON转义和 UTF-8多字节会放大。上限仍是有限值防止沙箱异常文件造成大内存读取。用户文件不能直接依赖 AtomicFile用户 URI写入流程constserializedContentserializeDocument(content,format);constexpectedBytesbuffer.from(serializedContent,utf-8).length;constwrittenBytesawaitfileIo.write(file.fd,serializedContent,{offset:0,encoding:utf-8});if(writtenBytes!expectedBytes){thrownewError(The complete document could not be written.);}awaitfileIo.truncate(file.fd,writtenBytes);awaitfileIo.fsync(file.fd);先从偏移0写确认完整字节数再 truncate去掉旧文件多余尾部最后 fsync请求同步到存储设备。finally始终关闭句柄。这里存在覆盖窗口因此保存前把磁盘旧内容、BOM和换行写入沙箱pending-save-backup.json。外部写入成功后清理备份失败时尝试写回旧版本进程中断后下次启动询问用户恢复旧文件或保留当前磁盘版本。保存备份也是原子记录备份记录允许最多二十兆字符文件字节上限128 MiB。它保存外部文件覆盖前的完整正文exportinterfacePendingSaveBackupRecord{version:number;documentUri:string;documentName:string;previousContent:string;hasUtf8Bom:boolean;lineEnding:string;updatedAt:number;}这份 JSON本身通过同一writeAtomicPayload写入。否则保护外部文件的备份若只写了一半故障恢复仍没有意义。备份在覆盖之前完成顺序不能倒置。保存后先更新内存基线再清理备份清理失败时状态栏显示 backup cleanup pending但用户文件已保存。下次启动仍可让用户决定不应因为清理失败把保存结果说成失败。故障注入比正常保存更重要ohosTest通过删除目标目录制造必然写入失败先创建旧内容和沙箱备份再删除文件与目录调用writeUtf8Document断言失败随后加载备份重建目录并恢复旧内容。letwriteFailedfalse;try{awaitwriteUtf8Document(faultPath,# 不应写入\n);}catch(_){writeFailedtrue;}expect(writeFailed).assertTrue();constbackupawaitloadPendingSaveBackup(context.filesDir);expect(backup!undefined).assertTrue();expect(backup?.previousContent).assertEqual(previousContent);正常路径只能证明 API在理想环境可用。故障注入证明失败不会清理唯一旧版本且恢复记录仍能读取。还应增加短写、fsync失败、权限撤销、进程在备份后终止、进程在外部写入后但清理前终止等场景。鸿蒙 PC 模拟器中的恢复结果下图显示恢复内容重新进入鸿蒙 PC应用。用户看见的是正文和未保存状态背后依赖恢复 JSON原子提交、启动校验和编辑器基线重建。截图不能证明原子性原子性需要强杀时序和设备测试它证明记录最终能回到真实 UI。技术证据应同时保存代码用例、测试结果和应用画面。当前边界用户外部 URI仍不是平台级原子替换安全性依赖写前沙箱备份。不同文件 provider对 truncate、fsync和权限的行为可能不同需要真机和云盘来源验证。备份正文未加密依赖应用沙箱卸载应用会删除恢复记录。记录长度校验不等于哈希校验AtomicFile的底层持久化保证也应以 HarmonyOS文档和设备行为为准。当前测试覆盖字节一致和目录故障尚未完成大规模随机断电测试。结语AtomicFile不是一行万能 API。可靠使用需要startWrite、等待end、finishWrite、失败时failWrite并在提交后按 UTF-8字节校验。业务记录还要写前验证、读时再验证。对于用户 URI应用用完整旧版本原子备份弥补非原子覆盖窗口再配合写入字节数、truncate、fsync和启动恢复。鸿蒙 PC Markdown 编辑器只有把失败路径设计成一等公民保存和恢复才真正具备工程可信度。