鸿蒙 PC Markdown 编辑器有界版本历史:沙箱快照、完整性校验与安全恢复

鸿蒙 PC Markdown 编辑器有界版本历史:沙箱快照、完整性校验与安全恢复

版本历史看起来像一个很常见的编辑器功能:保存若干旧文本,给用户一个列表,再提供"比较"和"恢复"按钮。但在面向鸿蒙 PC 的本地 Markdown 编辑器里,这个功能真正困难的地方不是列表怎么画,而是如何回答一组与数据安全直接相关的问题:哪个文件的历史?一次保存失败时以谁为准?外部程序已经改过磁盘文件时还能不能恢复?损坏的历史会不会拖垮当前文档?快照会不会无限增长?用户按下"恢复"之后,磁盘内容是否立刻被覆盖?

OhMarkdown 在 G4-05 没有把版本历史做成一套新的文档事实来源,而是把它限定成"工作区 Markdown 文件之外的、应用沙箱内的、可以丢失但不能伤害正文的辅助能力"。源码仓库地址是:https://gitcode.com/VON-/codex_md_oh。方案先通过架构提交 40a7745 固化,再由功能提交 6a36ce9 实现。本文从工程约束、存储模型、原子写入、完整性、清理、比较、安全恢复、鸿蒙 PC 界面和验证证据展开,所有结论均对应当前仓库中的真实代码与测试,不把模拟器结果包装成真机结论。

一、先定义事实来源,而不是先定义数据表

版本历史最容易犯的架构错误,是为了查询方便,把历史数据库逐步变成正文的真正来源。这样一来,用户在文件管理器里看到的 Markdown、编辑器当前缓冲区和数据库中的最新版本可能出现三个答案。一旦应用升级失败、数据库迁移失败或用户把文件复制到其他设备,所谓"本地优先"就只剩下宣传语。

OhMarkdown 的第一条不变量是:用户选择的 Markdown 文件始终是磁盘事实来源,编辑期间的 CodeMirror EditorState 是当前缓冲区事实来源,版本历史只保存可选的旧快照。应用不从历史目录推导"当前文件",也不在工作区写 .ohmarkdown 隐藏目录。历史位于应用沙箱,卸载应用时允许随沙箱被清理,但用户文件不能因此受损。

这个取舍带来几个直接收益。第一,用户用任何标准工具打开 Markdown,看到的仍然是标准文件。第二,历史服务不可用时,打开、编辑和保存仍然可以继续。第三,历史损坏只会让某一份版本不可读,不会阻止当前文档启动。第四,未来如果采用压缩、增量链或数据库,可以把它作为历史后端迁移,而不必改变用户文件模型。

对应的 ADR 明确拒绝了首版使用 Git、SQLite、云同步和工作区隐藏目录。Git 很适合项目级版本控制,却不适合作为每次自动保存的透明后端;它会引入仓库发现、锁、用户 Git 状态和大资源处理等额外语义。SQLite 可以提供索引与事务,但首版只有单机、单文档、最多 50 份快照,数据库迁移和故障面大于收益。完整 JSON 快照虽然不是空间效率最高的方案,却最容易做到单文件校验、单文件隔离和确定性清理。

二、用文档 URI 的 SHA-256 建立身份

历史目录不能直接使用文件名。两个目录都可以有 README.md,文件也可能包含中文、空格、冒号或系统不适合出现在路径中的字符。只用文件内容哈希也不够,因为两个独立文件可能恰好正文相同,却不应该共享版本链。

当前实现对修剪后的文档 URI 计算 SHA-256,得到固定 64 位十六进制文档键。URI 为空或超过 4096 字符时拒绝建立历史。这里没有把 URI 直接写进目录名,既消除了路径字符问题,也避免在沙箱目录结构中直接暴露完整用户路径。

ts 复制代码
export function createVersionHistoryDocumentKey(documentUri: string): string {
  const normalizedUri = documentUri.trim();
  if (normalizedUri.length === 0 || normalizedUri.length > MAX_DOCUMENT_URI_CHARACTERS) {
    throw new Error('The document URI is unavailable for version history.');
  }
  return createVersionHistoryHash(normalizedUri);
}

SHA-256 同时用于正文完整性。createVersionHistoryHash 先以严格 UTF-8 编码字符串,再调用鸿蒙 CryptoArchitectureKit 的 SHA-256 摘要。读取历史时重新计算正文哈希,只有与元数据完全一致的快照才能进入列表或恢复路径。

ts 复制代码
export function createVersionHistoryHash(value: string): string {
  const digest = cryptoFramework.createMd('SHA256');
  digest.updateSync({ data: TEXT_ENCODER.encodeInto(value) });
  return bytesToHex(digest.digestSync().data);
}

这里的哈希不是加密,也不试图隐藏正文。历史 JSON 仍然位于应用沙箱,安全性依赖鸿蒙应用隔离和受限文件访问。哈希解决的是"读取到的内容是否仍是写入时那份内容",不是"有权限的人能否看到内容"。把完整性与保密性分开,能避免用一个摘要函数承担它并不具备的能力。

三、完整快照为什么要记录格式信息

Markdown 正文不只有可见字符。一个文档可能有 UTF-8 BOM,可能使用 LF、CRLF,也可能是混合换行。如果历史只保存统一化后的字符串,恢复之后再保存就可能制造全文件 diff,甚至破坏依赖字节格式的外部流程。

因此快照除了 content,还记录 hasUtf8BomlineEnding。同时保存来源、时间、文件指纹和文档名,用于向用户解释版本从哪里来。当前数据结构如下:

ts 复制代码
export interface VersionHistorySnapshot {
  version: number;
  id: string;
  documentKey: string;
  documentName: string;
  contentHash: string;
  source: VersionHistorySource;
  createdAt: number;
  fingerprint: DocumentFingerprint | undefined;
  hasUtf8Bom: boolean;
  lineEnding: string;
  content: string;
}

version: 1 是存储格式版本,不是用户看到的版本序号。未来格式新增字段时,读取器必须显式识别并迁移,不能把任意 JSON 强制断言成最新结构。当前读取校验包括:ID 与文件名匹配、文档键匹配、哈希格式、来源枚举、整数时间戳、文件指纹边界、BOM 布尔值、换行枚举和正文长度。结构正确之后还要重算内容哈希。

快照 ID 使用 13 位毫秒时间戳加 12 位内容哈希前缀,例如 1784700000000-1a2b3c4d5e6f。时间戳碰撞时逐毫秒增加,直到目标文件不存在。完整正文哈希仍保存在 JSON 中,文件名短前缀只负责可读、排序和降低同毫秒碰撞概率,不能替代完整校验。

四、原子写入必须覆盖失败路径

直接调用一次 write 并不能保证所有字节都已经写入。底层写操作可能只推进一部分内容,进程也可能在写到一半时结束。版本历史虽然不是权威正文,但截断快照如果进入列表,会让用户在最需要恢复时遇到第二次失败。

服务先在同一目录写入 .json.tmp,循环调用 fileIo.write 直到全部字节完成,再执行 truncatefsync。只有这些步骤成功后才重命名为最终 .json。同目录重命名避免跨文件系统复制语义。

ts 复制代码
async function writeSyncedPayload(path: string, payload: string): Promise<number> {
  const bytes = TEXT_ENCODER.encodeInto(payload);
  const data = new ArrayBuffer(bytes.length);
  new Uint8Array(data).set(bytes);
  const file = await fileIo.open(path,
    fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
  try {
    let written: number = 0;
    while (written < data.byteLength) {
      const remaining = written === 0 ? data : data.slice(written);
      const count = await fileIo.write(file.fd, remaining);
      if (count <= 0) {
        throw new Error('The version history write made no progress.');
      }
      written += count;
    }
    await fileIo.truncate(file.fd, written);
    await fileIo.fsync(file.fd);
    return written;
  } finally {
    await fileIo.close(file);
  }
}

重命名成功仍不是结束。实现会读取最终文件大小,与预期 UTF-8 字节数比较,然后再走一次完整加载与哈希校验。最终文件大小或校验失败时,代码删除这份不可信文件。下次枚举前还会清理符合严格命名规则的临时文件。

这里要特别注意删除边界:程序不会看到 .tmp 就随便删除。临时文件名必须匹配固定的时间戳和哈希格式,lstat 必须确认它是普通文件且不是符号链接。否则一个构造出的链接可能把清理操作引向沙箱中其他位置。历史根目录下的文档目录也要求 64 位十六进制名、真实目录且非符号链接。

五、损坏隔离比"尽量解析"更重要

历史读取器对每个文件单独 try/catch。JSON 截断、字段类型错误、版本号不匹配、文件超限、符号链接、正文哈希错误都会让这一项返回 undefined,但不会使整个文档历史列表失败。只有目录本身无法枚举等基础设施错误,才会让面板显示"版本历史暂不可用"。

ts 复制代码
async function loadSnapshotFile(path: string, name: string,
  documentKey: string): Promise<SnapshotFile | undefined> {
  if (!SNAPSHOT_NAME_PATTERN.test(name)) return undefined;
  try {
    const stat = await fileIo.lstat(path);
    if (!stat.isFile() || stat.isSymbolicLink() ||
      stat.size <= 0 || stat.size > MAX_SNAPSHOT_FILE_BYTES) return undefined;
    const snapshot = JSON.parse(
      await fileIo.readText(path, { encoding: 'utf-8' })) as VersionHistorySnapshot;
    if (!isSnapshotStructureValid(snapshot, documentKey) ||
      `${snapshot.id}.json` !== name ||
      createVersionHistoryHash(snapshot.content) !== snapshot.contentHash) return undefined;
    return { path, snapshot, byteSize: stat.size };
  } catch (_) {
    return undefined;
  }
}

这种策略有意不自动"修复"损坏 JSON。没有可信哈希和结构时,程序无法知道丢失字符应该是什么。把猜测出来的文本交给恢复流程,风险高于隐藏这份损坏版本。当前文档和其他历史仍可使用,用户也可以继续保存形成新版本。

ohosTest 在真实应用沙箱目录中写入一份 {broken,然后重新列出历史。结果仍然只返回两份有效快照,最新自动保存正文可以读回,手动版本可以安全删除。这个测试不是只测纯函数,而是覆盖 Core File Kit 的创建、枚举、读取和删除链路。

六、有界不是一个数字,而是四道预算

没有边界的版本历史迟早会把磁盘占满。单纯限制"最多 50 份"也不够,因为 50 份 5 MiB 正文经过 JSON 编码后可能远超合理预算;只限制容量又可能让数千份小快照拖慢列表和清理。

OhMarkdown 首版同时设置四道边界:单份正文最多 5 MiB 字符;单文档最多 50 份;单文档历史最多 64 MiB;版本最多保留 90 天。所有文档历史总量另设 256 MiB 上限。保留算法按时间从新到旧遍历,过期、超数量或加入后超容量的候选不进入保留集合。

ts 复制代码
export function selectVersionHistoryRetention(
  candidates: Array<VersionHistoryRetentionCandidate>, now: number,
  maximumSnapshots: number = MAX_VERSION_HISTORY_SNAPSHOTS,
  maximumBytes: number = MAX_VERSION_HISTORY_DOCUMENT_BYTES
): Array<string> {
  const ordered = candidates.slice()
    .sort((left, right): number => right.createdAt - left.createdAt);
  const retained: Array<string> = [];
  let retainedBytes: number = 0;
  for (const candidate of ordered) {
    if (candidate.createdAt < now - VERSION_HISTORY_MAX_AGE_MILLISECONDS ||
      retained.length >= maximumSnapshots ||
      retainedBytes + candidate.byteSize > maximumBytes) continue;
    retained.push(candidate.id);
    retainedBytes += candidate.byteSize;
  }
  return retained;
}

自动保存还有一层时间合并。若最新版本也是自动保存,并且新自动保存发生在五分钟内,新快照提交成功后删除旧自动版本。注意顺序是"先提交新文件,再删除旧文件",不能为了节省空间先删旧版本。如果新写入失败,旧自动版本仍然存在。手动版本、普通保存、保存前基线和恢复前保护不参与该合并,因为它们代表用户或安全事务的明确边界。

相同正文则按完整内容哈希去重,无论来源是什么都不重复写。这可以避免用户连续点"创建版本"得到一排内容完全相同的条目。去重返回既有条目,界面明确提示相同内容已经存在,而不是伪造一次成功新增。

5 MiB 是"历史快照边界",不是"编辑器文件边界"。OhMarkdown 仍然可以打开和保存更大的文档,并在精确 10 MiB 时进入源码保护模式。超出 5 MiB 的正文只是不建立版本历史,不能因此阻止用户保存。把辅助功能的失败与主保存链隔离,是本地编辑器可靠性的必要条件。

七、保存历史不能反向破坏保存

显式保存一个已有文件时,系统先读取权威磁盘正文并建立"保存前基线",然后执行既有安全保存,成功后再记录新正文。这样用户既能回到保存前磁盘版本,也能找到保存后的版本。

但历史记录不是文件保存事务的一部分。如果用户文件已经安全写入,而沙箱历史随后因空间或权限问题失败,应用不能谎称保存失败,更不能把磁盘回滚到旧内容。当前实现返回一个历史记录成功标志,保存状态会显示"已保存;版本历史暂不可用"。用户最重要的数据操作已经完成,同时历史故障没有被静默隐藏。

自动保存同样遵循这个原则,只是来源标记为 AUTO_SAVE 并应用五分钟合并。未首次保存的 Untitled.md 没有稳定 URI,因此版本历史面板要求先保存。首版没有用标签名或随机会话 ID 构造长期历史,因为应用重启后无法可靠确认匿名文档身份。

八、比较历史时必须把正文当不可信文本

历史正文来自用户文件,可能包含 HTML、脚本事件属性或非常长的单行。如果比较面板把 Markdown 直接设置为 innerHTML,版本历史就会成为新的脚本执行入口。

Web 侧复用已有三方差异算法,但新增明确的 version 模式。调用时把历史、当前、历史传给既有差异计算,然后隐藏磁盘第三栏与冲突图例,界面只显示"历史版本"和"当前缓冲区"。内容仍通过文本节点渲染。Playwright 特意把 <img src=x onerror=alert(1)> 放入历史正文,断言比较区能看到原始字符串,同时不存在任何 img 元素。

ts 复制代码
function showVersionDiff(current: string, historical: string,
  metadata: VersionDiffMetadata = {}): void {
  const comparisonMetadata: ThreeWayDiffMetadata = {
    baselineFormat: metadata.historicalFormat,
    localFormat: metadata.currentFormat,
    diskFormat: metadata.historicalFormat
  };
  showThreeWayDiff(historical, current, historical, comparisonMetadata);
  activeThreeWayComparison = {
    mode: 'version', baseline: historical, local: current,
    disk: historical, metadata: comparisonMetadata, versionMetadata: metadata
  };
  conflictComparison.dataset.comparisonMode = 'version';
}

当两份正文总量超过高亮比较预算时,ArkUI 不再要求 Web 构建巨大差异 DOM,而是显示当前行数、历史行数、首处差异行和有界预览。这是一种功能降级,但不会改变正文,也不会因为"必须漂亮比较"拖垮编辑会话。

比较面板支持中英文即时切换。语言变化只更新标题和栏标签,不重新加载正文、不改变选区,也不写入历史。这条路径延续了项目现有界面语言状态闭环。

九、安全恢复的关键是"不立即保存"

传统"恢复此版本"按钮很容易被理解为直接把旧内容写回磁盘。这样做的问题是:当前缓冲区可能还有未保存编辑,外部程序可能已经修改文件,用户也可能只是想取回历史中的一段文字。直接覆盖会把可逆的查看动作变成高风险写入。

OhMarkdown 的恢复语义分成四步:先显示确认对话框;捕获当前活动编辑器缓冲区;把当前缓冲区写成 RESTORE_SAFETY 保护快照;把选中历史正文载入同一 CodeMirror 会话并标记为脏。磁盘文件在整个过程中不被写入。只有用户随后明确保存,既有外部冲突检查和安全保存才会运行。

ts 复制代码
await createVersionHistorySnapshot(context.filesDir, {
  documentUri,
  documentName: this.documentName,
  content: this.documentContent,
  source: VersionHistorySource.RESTORE_SAFETY,
  format: this.documentFormat,
  fingerprint: this.documentFingerprint
});
this.documentContent = snapshot.content;
this.documentDirty = true;
this.operationStatus = 'Historical version loaded; not saved';
this.setEditorDocument(snapshot.content, true);
this.syncActiveDocumentSession(snapshot.content);

恢复确认文案明确告诉用户不会直接覆盖磁盘。恢复完成后,标签出现未保存标记。更重要的是,如果恢复之前已经存在外部修改冲突,恢复不会把冲突状态清空。用户仍需在保存前处理磁盘版本与本地缓冲区之间的关系。这保证版本历史不会成为绕过外部冲突保护的后门。

恢复前保护快照也可能因为正文超过 5 MiB 或沙箱异常而失败。此时恢复操作必须停止,不能在没有保护当前缓冲区的情况下继续载入旧版本。相比"尽量恢复",这里选择了更保守的事务边界,因为当前未保存内容的价值未知。

十、鸿蒙 PC 侧栏需要完整的桌面任务链

版本历史入口位于活动栏,不挤占文件、搜索和大纲面板。未保存文档显示保存优先提示;已保存文档显示条目数量和刷新按钮。每个条目包含时间、来源、内容哈希前 12 位、JSON 文件大小和换行格式,并提供比较、恢复和删除三个明确动作。

来源不是装饰信息。用户可以区分手动版本、自动保存、普通保存、保存前基线和恢复前保护。这比只显示"今天 14:30"更有解释力:恢复前保护意味着它通常是一次高风险动作之前的缓冲区,保存前基线意味着它来自磁盘权威内容。

列表异步刷新使用请求序号和文档 URI 双重判断。用户在枚举过程中切换标签时,旧请求即使较晚返回,也不能覆盖新活动文档的历史列表。比较和恢复进一步捕获会话 ID 与 URI,读取完成后再次核验,避免把 A 文档的历史加载到 B 文档。

按钮使用固定高度和稳定布局,当前鸿蒙 PC 模拟器侧栏宽度下没有文字截断或相互覆盖。历史条目可滚动,面板底部的手动创建按钮保持可达。操作进行时相关按钮禁用,避免双击产生并发恢复或删除。

十一、测试不能只覆盖"创建成功"

G4-05 最终 Web Playwright 为 56/56。新增用例验证双栏比较模式、中文和英文标签、第三栏隐藏、差异摘要,以及恶意 HTML 始终以纯文本出现。既有 55 项回归同时覆盖编辑、预览、图片、搜索、链接、冲突、即时渲染和结构化编辑,证明历史比较没有破坏原来的差异基础设施。

ArkTS 单元测试覆盖保留算法:新到旧排序、过期剔除、数量预算、容量预算和输入数组不被修改。ohosTest 则在真实应用沙箱执行完整文件链:创建手动版本、重复正文去重、两次自动保存合并、注入损坏 JSON、列出有效版本、读回正文、删除指定版本。最终 13/13,Failure 0、Error 0,总耗时 2056 ms。

最终产物也记录了可复核哈希:生产单 HTML 为 7,688,629 字节,SHA-256 b132a4ffe069ed9afa1fdfa0716e9f952e599c185a75bce8a9d7b287f0268d99;Debug HAP 为 8,675,813 字节,SHA-256 7b79f73b02c73219eb471d28fc50463f24cebff234a4e362ab6a96a9d29c3b12;ohosTest HAP 为 9,418,089 字节,SHA-256 9ab4fba5dc94db241ef3553e6310520f6109eab6bf6da84ffbccf1d347275930

设备验证使用 DevEco Studio MateBook Pro 2in1 模拟器,完成未保存边界、首次保存、手动版本、版本列表、两栏比较、恢复确认和恢复为脏缓冲区。截图分辨率均为 3120 x 2080。模拟器中的测试文件随后出现外部修改冲突;历史恢复后冲突横幅仍保留,恰好验证恢复没有越过既有文件安全状态机。

第一次执行 ohosTest 时,命令把模块参数误写为 entry,框架报告 App died。将模块更正为真实测试模块 entry_test 后,全套 13/13 通过。这个过程被保留在测试报告中,因为验证工程也需要可解释,不能只留下最后一行绿色数字。

十二、当前边界与下一阶段

完整 JSON 快照是明确的首版取舍。它不适合无限历史,也不适合数百 MiB 文档,但在 50 份、64 MiB 单文档预算内具备最简单的隔离与恢复语义。若未来引入压缩,必须明确压缩炸弹、解压预算和哈希对象;若引入增量链,必须处理链中单点损坏、重建和压缩基线;若引入数据库,必须提供模式迁移、事务恢复和卸载/清理策略。这些变化都不能在现有格式上悄悄发生,需要新的 ADR。

全局 256 MiB 清理本轮没有在模拟器灌满真实数据。当前证据来自保留纯函数、代码审查和较小规模沙箱读写;长时间自动保存、异常断电、存储紧张、应用升级与 256 MiB 压力需要在 G4-08 和 RC 专项中继续。应用卸载会删除沙箱历史,首版也不提供历史导出和跨设备同步。

文档移动或 URI 变化会创建新的历史链。首版没有根据内容哈希猜测"这是同一个文件",因为重名、复制和模板文档会导致错误归并。多窗口阶段还要进一步定义同一 URI 在多个窗口打开时的历史刷新与恢复互斥,但不能用全局正文单例解决,否则会破坏窗口会话隔离。

按照竞争优势记分规则,有界版本历史当前可以记为 3 分:自动化、真实沙箱 ohosTest、失败路径和鸿蒙 PC 模拟器任务链证据完整。它还不能记为 4 分,因为没有鸿蒙 PC 真机异常断电和长期压力,也没有在相同设备、相同文档、相同操作边界下与 Typora、Obsidian、VS Code 等竞品测量恢复耗时与误操作风险。

G4-05 的价值不在于多了一个时钟图标,而在于它没有破坏编辑器原有的可靠性层次:Markdown 文件仍然开放,当前缓冲区仍然明确,历史故障仍然局部,恢复仍然可撤销,外部冲突仍然受保护,存储增长仍然有上限。下一步 G4-06 将进入鸿蒙 PC 多窗口与会话恢复,需要继续保持同样的原则:先定义所有权和冲突语义,再增加窗口数量,不能让多个界面共享一份无法解释的全局正文状态。

相关推荐
痕忆丶2 小时前
OpenHarmony北向开发基础之 沙箱机制+分布式文件
harmonyos
7177773 小时前
Gitee DevOps 国产化研运能力解析:安全与研发效能双维度升级
安全·gitee·devops
treesforest3 小时前
公开IP属地后我们应该如何保护自身网络安全?
tcp/ip·安全·web安全·ip属地·查ip归属地
logomister设计公司阿燕3 小时前
华为商标“减法哲学”:极简主义如何成就全球品牌?
python·华为
Sagittarius_A*3 小时前
Web 安全之 Git 泄露:原理剖析 + CTFHub Log/Stash/Index 全题型解法
git·安全·web安全
youtootech12 小时前
HarmonyOS《柚兔学伴》项目实战25-我的页面、Web 嵌入与项目总结
前端·华为·harmonyos
三声三视14 小时前
uni-app 鸿蒙端传参变成 [object Object]?顺着源码追到 ArkTS router 底层才搞明白
人工智能·ai·uni-app·aigc·ai编程·harmonyos
达子66614 小时前
第7章_HarmonyOS 图解 Ability公共事件与通知
华为·harmonyos
爱写代码的阿森14 小时前
鸿蒙三方库 | harmony-utils之KvUtil键值型数据库操作详解
数据库·华为·harmonyos·鸿蒙·huawei