鸿蒙 PC Markdown 编辑器存储安全:AtomicFile 原子提交与故障注入
恢复文件本来用于保护用户内容,如果它自己在进程终止时只写入半截 JSON,下一次启动就无法解析;如果应用覆盖用户文件失败却没有旧版本,安全保存反而扩大损失。存储可靠性的关键不是"调用写入 API没有抛错",而是为每笔状态建立提交边界、回滚路径和可验证结果。
本文基于鸿蒙 PC Markdown 编辑器 OhMarkdown,分析 HarmonyOS Core File Kit AtomicFile 的正确使用、流结束与提交顺序、字节长度校验,以及用户文件写入前的沙箱备份和故障注入。完整代码位于 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字节。写入函数先计算:
ts
const expectedBytes = buffer.from(
payload,
'utf-8'
).length;
后续 stat比较必须使用这个值,不能用 payload.length。例如一个中文字符 JavaScript长度通常为1,UTF-8却为3字节;emoji还涉及 UTF-16代理对。用字符数比较文件大小会误报或漏报短写。
正确顺序是 end、finish、stat
核心实现如下:
ts
async function writeAtomicPayload(
path: string,
payload: string
): Promise<void> {
const expectedBytes = buffer.from(
payload,
'utf-8'
).length;
const atomicFile = new fileIo.AtomicFile(path);
let writeStarted: boolean = false;
try {
const writeStream = atomicFile.startWrite();
writeStarted = true;
await new Promise<void>((resolve, reject) => {
writeStream.end(
payload,
'utf-8',
(error?: Error) => {
if (error) {
reject(error);
} else {
resolve();
}
}
);
});
atomicFile.finishWrite();
writeStarted = false;
const persistedStat = await fileIo.stat(path);
if (persistedStat.size !== expectedBytes) {
throw new Error(
`The atomic record expected ${expectedBytes} bytes ` +
`but persisted ${persistedStat.size}.`
);
}
} catch (error) {
if (writeStarted) {
try {
atomicFile.failWrite();
} catch (_) {
}
}
throw error instanceof Error
? error
: new Error(String(error));
}
}
startWrite取得写流并进入事务。writeStream.end不仅写 payload,还关闭流;Promise等待回调确认完成。之后才能 finishWrite提交。提交完成后 stat最终路径并比较字节。
曾经使用 writeStream.write后直接 finish。write回调完成不一定等于流完全结束,底层缓冲与句柄生命周期可能尚未收口。改为 end明确表达"这是最后一段数据",修复了设备测试中的完整性问题。
writeStarted 是回滚状态
writeStarted只在 startWrite成功后为 true,finish成功后立即变 false。catch中只有事务仍进行时调用 failWrite。
如果 startWrite本身抛错,没有临时事务可回滚;如果 finish已经提交,后续 stat发现长度异常,此时无法再把已提交事务当未提交回滚,错误继续向上传播。状态变量让回滚动作对应真实生命周期,而不是在所有异常上盲调 failWrite。
failWrite本身也可能失败,所以嵌套 try/catch不覆盖原始错误。错误报告应保留最初写入原因,而不是被清理异常替换。
写后长度校验不是内容校验
stat大小相等可以发现零字节、短写和编码长度不符,但无法发现同长度内容损坏。恢复 JSON在下次读取时还会 JSON.parse并验证字段;测试可以在关键记录写后立即读回比较哈希,代价是额外 I/O。
当前恢复记录小于五兆,写后长度与启动解析形成两级检查。若设备可靠性数据暴露静默损坏,应增加读回哈希。不要把 finishWrite返回无异常当成端到端证明。
原子记录写入前先校验
恢复记录只有满足版本、URI、名称、正文大小、BOM、换行、revision和时间约束才写:
ts
export async function saveRecoveryRecord(
filesDir: string,
record: RecoveryRecord
): Promise<void> {
if (!isRecoveryRecordValid(record)) {
throw new Error(
'The recovery record is invalid or exceeds ' +
'the 5 MiB Alpha limit.'
);
}
const payload = JSON.stringify(record);
await ensureRecoveryDirectory(filesDir);
await writeAtomicPayload(
getRecoveryPath(filesDir),
payload
);
}
原子提交只能保证"完整写入这串字节",不能判断这串字节是否值得恢复。写前业务校验和原子文件语义各自解决不同问题。
目录通过 mkdir(..., true)按需创建。路径全部位于 context.filesDir/recovery,不向公共 Documents泄露草稿,也不要求额外用户授权。
读取端防御损坏和超大文件
读取恢复记录先 access,再 stat:
ts
const stat = await fileIo.stat(recoveryPath);
if (stat.size <= 0 ||
stat.size > MAX_RECOVERY_FILE_BYTES) {
return undefined;
}
try {
const record = JSON.parse(
await fileIo.readText(recoveryPath, {
encoding: 'utf-8'
})
) as RecoveryRecord;
return isRecoveryRecordValid(record)
? record
: undefined;
} catch (_) {
return undefined;
}
空文件、过大文件、非法 UTF-8/JSON和字段不合法都被忽略,不让应用启动失败。恢复文件是辅助数据,损坏时最安全行为是继续启动并保留错误可观测信息,而不是崩溃循环。
文件字节上限高于正文字数上限,因为 JSON转义和 UTF-8多字节会放大。上限仍是有限值,防止沙箱异常文件造成大内存读取。
用户文件不能直接依赖 AtomicFile
用户 URI写入流程:
ts
const serializedContent = serializeDocument(
content,
format
);
const expectedBytes = buffer.from(
serializedContent,
'utf-8'
).length;
const writtenBytes = await fileIo.write(
file.fd,
serializedContent,
{ offset: 0, encoding: 'utf-8' }
);
if (writtenBytes !== expectedBytes) {
throw new Error(
'The complete document could not be written.'
);
}
await fileIo.truncate(file.fd, writtenBytes);
await fileIo.fsync(file.fd);
先从偏移0写,确认完整字节数,再 truncate去掉旧文件多余尾部,最后 fsync请求同步到存储设备。finally始终关闭句柄。
这里存在覆盖窗口,因此保存前把磁盘旧内容、BOM和换行写入沙箱 pending-save-backup.json。外部写入成功后清理备份;失败时尝试写回旧版本;进程中断后下次启动询问用户恢复旧文件或保留当前磁盘版本。
保存备份也是原子记录
备份记录允许最多二十兆字符,文件字节上限128 MiB。它保存外部文件覆盖前的完整正文:
ts
export interface PendingSaveBackupRecord {
version: number;
documentUri: string;
documentName: string;
previousContent: string;
hasUtf8Bom: boolean;
lineEnding: string;
updatedAt: number;
}
这份 JSON本身通过同一 writeAtomicPayload写入。否则保护外部文件的备份若只写了一半,故障恢复仍没有意义。
备份在覆盖之前完成,顺序不能倒置。保存后先更新内存基线,再清理备份;清理失败时状态栏显示 backup cleanup pending,但用户文件已保存。下次启动仍可让用户决定,不应因为清理失败把保存结果说成失败。
故障注入比正常保存更重要
ohosTest通过删除目标目录制造必然写入失败:先创建旧内容和沙箱备份,再删除文件与目录,调用 writeUtf8Document,断言失败;随后加载备份,重建目录并恢复旧内容。
ts
let writeFailed = false;
try {
await writeUtf8Document(
faultPath,
'# 不应写入\n'
);
} catch (_) {
writeFailed = true;
}
expect(writeFailed).assertTrue();
const backup = await loadPendingSaveBackup(
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 编辑器只有把失败路径设计成一等公民,保存和恢复才真正具备工程可信度。