鸿蒙 PC Markdown 编辑器存储安全:AtomicFile 原子提交与故障注入

鸿蒙 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、等待 endfinishWrite、失败时 failWrite,并在提交后按 UTF-8字节校验。业务记录还要写前验证、读时再验证。

对于用户 URI,应用用完整旧版本原子备份弥补非原子覆盖窗口,再配合写入字节数、truncate、fsync和启动恢复。鸿蒙 PC Markdown 编辑器只有把失败路径设计成一等公民,保存和恢复才真正具备工程可信度。

相关推荐
zzq77972 小时前
加固包闪退四象限定位:targetSdk 与保护策略实战解析
android·安全·安卓·安全架构
qizayaoshuap3 小时前
# 颜色混合器 — HarmonyOS RGB调色板与Slider组件实战
华为·harmonyos
Promise微笑3 小时前
智能激光清障仪选型指南:高效运维与安全保障的关键考量
运维·安全
不言鹅喻4 小时前
HarmonyOS ArkTS 实战:实现一个校园体育场馆预约应用
pytorch·华为·harmonyos
网络工程小王4 小时前
【HCIE-AI】11.模型 昇腾迁移适配-精度调试-性能调优
人工智能·学习·华为·迁移学习·昇腾
xianjixiance_4 小时前
HarmonyOS开发实战:小分享-ArkUI 基础组件详解——Text、Button、Image
华为·harmonyos
ov二号5 小时前
鸿蒙原生ArkTS布局方式之侧边栏SideBarContainer布局深度指南
华为·harmonyos
echohelloworld115 小时前
HarmonyOS开发实战:笔友-EmptyState 空状态组件的条件式占位设计
harmonyos·鸿蒙
qizayaoshuap6 小时前
# 倒计时器 — HarmonyOS TextInput与计时任务管理深入实践
pytorch·深度学习·华为·harmonyos