鸿蒙 PC Markdown 编辑器分享缓存生命周期治理
仓库地址:https://gitcode.com/VON-/codex_md_oh
代码基线:系统分享实现6c823eb,缓存生命周期收口2b82214。
系统分享为什么需要本地快照
鸿蒙 PC Markdown 编辑器发起系统分享时,不能把工作区原文件 URI 直接交给任意接收方。原文件可能位于用户授权目录,URI 权限范围比一次分享更大;当前标签还可能包含尚未保存的正文,磁盘内容并不是用户此刻看到的版本。OhMarkdown 因此从 CodeMirror 当前缓冲区获取快照,在应用cacheDir中生成独立 Markdown 文件,只给本次系统目标临时读取这一条 URI。
这个方案同时保护两个事实:分享内容与当前编辑器一致,接收方又拿不到工作区原始授权。代价是缓存目录会出现含有真实正文的share-*.md。如果应用只依赖系统将来某个时刻清理 cacheDir,不同文档名会不断产生不同文件,本地敏感内容的留存时间和数量都没有应用级保证。
缓存文件位于沙箱,并不等于可以永久保留。攻击面不仅来自其他应用直接访问,还包括设备备份、调试工具、崩溃取证、越狱环境和后续代码误用。安全设计应按“完成任务所需的最小数据、最短时间、最小能力”处理,而不是把沙箱当作无限期档案库。
生命周期问题不是普通临时文件清理
分享快照与普通构建缓存不同。系统分享面板打开后,接收应用可能稍后才读取 URI;如果 OhMarkdown 在startAbility返回后立即删除文件,某些目标会看到空文件或读取失败。如果从不删除,用户连续分享不同文章又会积累正文。生命周期治理必须在可用性与最小留存之间设置明确边界。
本轮采用双重限制:快照最多保留最近 32 份,文件超过 24 小时即进入清理范围。当前正在分享的文件始终保留,不因年龄或数量被本轮清理。这个策略不是声称接收方一定在 24 小时内读取,而是给 Beta 建立一个保守、可测试的应用级上限;真机兼容目标验证仍决定后续是否调整期限。
数量和时间缺一不可。只有 24 小时过期,用户在一天内批量分享数百个不同文件时仍会形成短期峰值;只有 32 份上限,长期不再使用的 32 份敏感正文可能永久留下。两条规则共同限制稳态与突发规模。
清理范围必须采用严格命名空间
cacheDir可能包含图片缩略图、Web 缓存、系统组件文件或未来其他功能的临时数据。分享清理不能把整个目录当作自己所有。OhMarkdown 只处理名称同时满足share-前缀和.md后缀的条目,并再次拒绝斜杠、反斜杠和 NUL。
constSHARE_CACHE_PREFIX:string='share-';constSHARE_CACHE_SUFFIX:string='.md';constMAX_SHARE_CACHE_FILES:number=32;constSHARE_CACHE_RETENTION_MILLISECONDS:number=24*60*60*1000;if(!name.startsWith(SHARE_CACHE_PREFIX)||!name.endsWith(SHARE_CACHE_SUFFIX)||name.includes('/')||name.includes('\\')||name.includes('\u0000')){continue;}文件名白名单不是路径安全的全部。枚举后仍使用lstat,目录与符号链接直接跳过。这样即使缓存目录中出现名为share-example.md的目录,或者某个异常过程创建了指向其他位置的链接,清理器也不会递归进入或跟随目标。
为什么要跳过符号链接
删除 API 接收的是路径。若应用只看名字而跟随符号链接,理论上可能对预期之外的对象执行操作,具体风险取决于平台unlink语义和沙箱能力。安全代码不需要押注系统恰好阻止危险行为,而是先用lstat明确对象类型。
constpath:string=`${cacheDirectory}/${name}`;try{conststat=awaitfileIo.lstat(path);if(stat.isDirectory()||stat.isSymbolicLink()){continue;}entries.push({name:name,path:path,modifiedTime:normalizeModifiedTimeMilliseconds(stat.mtime)});}catch(_){}这里的异常隔离也很重要。目录正在被系统或另一个操作修改时,某个条目可能在listFile与lstat之间消失。单个条目失败不应让用户本次分享失败,也不应阻止其他可清理条目继续处理。
修改时间存在秒与毫秒陷阱
JavaScript 的Date.now()返回毫秒,而 HarmonyOS 当前模拟器的stat.mtime返回秒级 Unix 时间。如果直接相减,新创建文件会得到一个约一千倍量级的“年龄”,立即被判定超过 24 小时。第一次设备测试正好捕获了这个问题:连续生成 36 份快照后只剩当前 1 份,而预期是 32 份。
实现没有把失败归咎于模拟器,也没有放宽断言,而是统一时间单位。十位以下的正时间按秒转换为毫秒,已经是毫秒的值原样保留。
functionnormalizeModifiedTimeMilliseconds(modifiedTime:number):number{returnmodifiedTime>0&&modifiedTime<10_000_000_000?modifiedTime*1000:modifiedTime;}阈值10_000_000_000大于当前秒级时间戳、远小于当前毫秒级时间戳,足以区分两个平台表现。这个修复还说明系统 API 的时间单位必须在设备上验证,不能仅凭类型都是number就假设语义相同。
排序决定谁被保留
清理器按修改时间从新到旧排序,时间相同时再按名称稳定排序。遍历中维护已保留数量:当前分享路径优先保留;未过期且数量未达 32 的条目继续保留;其余条目尝试删除。
entries.sort((left:ShareCacheEntry,right:ShareCacheEntry):number=>right.modifiedTime-left.modifiedTime||right.name.localeCompare(left.name));letretainedCount:number=0;for(constentryofentries){if(entry.path===preservedPath){retainedCount+=1;continue;}constexpired:boolean=now-entry.modifiedTime>=SHARE_CACHE_RETENTION_MILLISECONDS;if(!expired&&retainedCount<MAX_SHARE_CACHE_FILES){retainedCount+=1;continue;}awaitfileIo.unlink(entry.path).catch(()=>{});}当前路径不参与淘汰是硬约束。即使设备时钟跳变、旧同名文件时间异常或目录已达到上限,本次即将发送的 URI 都必须存在。其余文件按最新优先,符合用户更可能继续使用最近分享内容的经验假设。
清理为什么放在完整写入之后
分享快照先创建、写入、检查完整 UTF-8 字节数、截断并fsync,关闭句柄后才运行清理。最后把当前路径转换为只读分享 URI。这样清理器可以把当前文件纳入总数量,又不会在正文尚未持久化时把路径交给系统。
constfile=awaitfileIo.open(path,fileIo.OpenMode.CREATE|fileIo.OpenMode.READ_WRITE);try{constexpectedBytes:number=buffer.from(content,'utf-8').length;constwrittenBytes:number=awaitfileIo.write(file.fd,content,{offset:0,encoding:'utf-8'});if(writtenBytes!==expectedBytes){thrownewError('The complete share document could not be written.');}awaitfileIo.truncate(file.fd,writtenBytes);awaitfileIo.fsync(file.fd);}finally{awaitfileIo.close(file);}awaitcleanupMarkdownShareCache(cacheDirectory,path);returnfileUri.getUriFromPath(path);这条顺序保持了文件功能的基本原则:只有完整数据才能产生成功 URI。清理是安全增强,不应该让未完成写入变成可分享对象,也不能改变原始 Markdown 工作区文件。
清理失败为何不阻断分享
存储层可能暂时拒绝枚举、lstat或删除。若清理失败直接抛到用户界面,用户会在当前快照已经安全写好时仍看到“分享失败”,而真正的系统分享甚至没有机会打开。生命周期治理应降低风险,但不应把非关键旧文件删除升级成当前任务的单点故障。
因此枚举失败返回删除数 0,单条检查和删除失败被隔离。下一次用户分享还会再次清理,系统自身也可能回收 cacheDir。这个“尽力而为”只适用于旧缓存;当前正文写入、字节完整性、fsync和 URI 创建仍是必须成功的步骤,不能吞掉。
如果未来观察到持续清理失败,应增加不含路径与文件名的本地诊断计数,或者在设置中提供明确的“清理分享缓存”动作。当前阶段不引入遥测,也不记录用户文档名称。
系统分享仍保持最小权限
快照写好后,WorkspaceShell 通过系统隐式 Want 发起sendData,类型为text/markdown,URI 同时写入 stream 参数,只授予临时读取权限。接收方得到的是快照 URI,不是工作区原文件 URI,也没有目录级授权。
constshareWant:Want={action:'ohos.want.action.sendData',type:'text/markdown',uri:shareUri,flags:wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION,parameters:{'ability.params.stream':shareUri,'ohos.extra.param.key.contentTitle':this.getExportName('md')}};awaitcontext.startAbility(shareWant);缓存治理没有扩大权限、没有新增网络,也没有改成更宽泛的text/plain来增加模拟器目标数量。产品继续保持 Markdown 语义,兼容接收方缺失仍作为环境缺口记录。
鸿蒙 PC 系统分享界面证据
下图来自 OhMarkdown 在 HarmonyOS MateBook Pro 2in1 模拟器中打开系统分享界面的真实结果。它证明应用已把当前快照交给系统分享路径,系统能够展示目标选择承载面。
截图不能证明 32 份上限,也不能证明某个兼容应用成功读取 Markdown。缓存数量由 ohosTest 文件枚举断言证明;正文正确由readUtf8Document重读证明;接收目标成功仍需要安装兼容应用的真机或完整镜像。视觉、存储和系统能力证据必须分开解释。
设备测试怎样覆盖误删除
新增 ohosTest 在独立测试目录先写入keep.txt,随后连续创建 36 个不同名称的 Markdown 分享快照。最终枚举严格匹配的share-*.md必须恰好为 32,最新 URI 重读必须得到“正文-35”,无关keep.txt必须仍存在。
awaitwriteRawText(`${shareCacheDirectory}/keep.txt`,'unrelated cache');letlatestUri:string='';for(letindex=0;index<36;index+=1){constsequence=index.toString().padStart(2,'0');latestUri=awaitcreateMarkdownShareUri(shareCacheDirectory,`文章-${sequence}.md`,`正文-${sequence}`);}expect(shareNames.length).assertEqual(32);expect(awaitfileIo.access(`${shareCacheDirectory}/keep.txt`)).assertTrue();expect((awaitreadUtf8Document(latestUri)).content).assertEqual('正文-35');这个用例同时检查上限、当前内容与所有权边界。只检查目录数量会允许错误实现删除无关文件;只检查最新文件会允许旧快照无限增长;只检查函数返回值则无法证明设备文件系统上的真实结果。
首次失败为什么必须保留在记录中
首次构建的新测试运行11项,其中缓存用例失败:期望 32,实际 1。其余 10 项通过。失败直接暴露mtime秒与Date.now()毫秒混用。修正单位后重新构建 ohosTest HAP、重新安装并完整运行,最终11/11,Failure 与 Error 都为 0。
保留失败原因能证明测试不是装饰。若报告只留下最终绿色数字,后续维护者很难理解时间归一化函数为什么存在,可能把它当成“多余兼容代码”删除。设备差异正是原生工程需要测试的内容。
最终回归还执行 Playwright44/44、Vite 单 HTML 构建、Debug HAP、ArkTS UnitTestBuild 和 diff check。分享缓存属于 ArkTS/Core File Kit 路径,Web 测试不会直接观察文件删除,但全量门禁可以证明改动没有破坏编辑、预览和导出。
新增的超限文件搜索断言
同一次原生质量收口还把工作区搜索的跳过计数变成设备证据。测试在两个正常 Markdown 之外创建一个大小为 4 MiB 加 1 字节的稀疏文件。服务应发现 3 份文档、扫描 2 份、跳过 1 份,正常两条搜索结果保持不变。
这个断言没有扩大搜索上限,而是验证既有安全预算可解释。用户面对超限文件时,搜索摘要能区分“没有命中”和“文件未扫描”。压力测试不仅要追求快,也要证明限制不会静默改变结论。
与系统自动清理的关系
应用级策略不取代系统 cacheDir 管理。系统仍可以因空间压力更早删除缓存,OhMarkdown 不能把快照当作长期可靠存储。工作区原文与恢复记录分别有自己的安全保存策略,分享缓存只是一次用户动作的临时副本。
反过来,系统可能在空间充足时长期不清理,应用的 24 小时和 32 份限制因此仍有价值。两个层级的职责不同:系统负责全局资源,应用负责理解哪些数据敏感、哪些文件属于自己、当前任务需要保留哪一份。
真机仍需验证什么
第一,安装真实支持text/markdown的接收应用,验证系统授予的临时 URI 可以读取完整未保存正文。第二,分享面板长时间停留、切换应用、设备休眠再恢复后,接收方是否仍能在 24 小时窗口内读取。第三,多次同时分享不同文档时,旧目标是否依赖超过最近 32 份的快照。
第四,设备时钟前后跳变、时区变化和不同系统版本的mtime单位是否仍被归一化。第五,系统提前回收 cacheDir 时,错误提示能否让用户恢复。完成这些任务后才适合把 24 小时写成正式兼容承诺,当前只是经过模拟器验证的 Beta 策略。
为什么不在本轮引入分享数据库
可以为每个快照建立数据库记录、引用计数、接收方回调和后台任务,但系统分享目标通常不会向发送应用可靠回报“读取完成”。引入数据库也会增加迁移、损坏、隐私和后台调度成本,却不能解决缺少生命周期回执的根本问题。
当前 Level 2、D2 边界下,基于文件名、修改时间、数量与当前路径的策略已经解决真实 P2,不改变工作区模型,也不新增长期存储格式。后续只有系统 API 提供可靠完成信号,或真实测试证明 24 小时/32 份不足,才需要升级设计并记录 ADR。
对鸿蒙 PC 产品优势的贡献
Markdown 编辑器的本地优先不能只看“没有 INTERNET 权限”。正文还会出现在恢复记录、保存备份、导出文件、图片缓存和分享快照中,每种副本都需要独立生命周期。分享缓存收口把安全从传输边界推进到数据留存边界。
用户不会因为目录里少了四个临时文件就直接感知功能增加,但长期产品的信任正来自这些细节:未保存正文能正确分享,原工作区 URI 不外露,旧快照不会无限增长,无关缓存不被误删,清理失败又不阻断当前任务。这些约束共同优于“按钮能弹出面板”的功能完成定义。
阶段结论
提交2b82214完成 G3-10 的分享缓存生命周期小阶段。OhMarkdown 现在只清理严格属于自身的普通share-*.md,保留当前 URI,最多留存最近 32 份,并清理超过 24 小时的快照。时间单位在真实模拟器失败后完成秒/毫秒归一化。
最终 Playwright44/44、Debug HAP、ArkTS UnitTestBuild、ohosTest HAP 构建和模拟器11/11通过。内部安全评审 SEC-G3-01 的无界留存问题关闭,但 G3 仍为8/10:兼容 Markdown 接收方、系统 PDF、鸿蒙 PC 真机、竞品统一测试、真实封闭测试和退出报告尚未完成。阶段没有满足进入 G4 的条件,因此继续收口第三阶段,而不是提前消费第四阶段范围。