鸿蒙 PC Markdown 编辑器持久图片预览:受限读取、分块 Bridge 与 Blob URL 生命周期

发布时间:2026/7/22 6:32:34
鸿蒙 PC Markdown 编辑器持久图片预览:受限读取、分块 Bridge 与 Blob URL 生命周期 鸿蒙 PC Markdown 编辑器持久图片预览受限读取、分块 Bridge 与 Blob URL 生命周期图片粘贴成功时看到预览并不代表功能完成。浏览器可以临时显示剪贴板 File 的 Blob URL但保存 Markdown、关闭应用再打开后内存 URL 已经失效若为了恢复预览直接给 ArkWeb 用户文件 URI又会扩大页面文件权限和路径攻击面。OhMarkdown 的持久图片预览因此采用反向请求Web 只识别受管理相对链接原生层验证并读取真实文件分块传回字节Web 构建只在当前会话有效的 Blob URL。实现已进入公开仓库 https://gitcode.com/VON-/codex_md_oh。第一纵切提交为0a02ce3设备文件、重新打开预览和拖放收口提交为89a5e57当前验证基线为0d8d38b。本文聚焦“保存后重新打开仍显示图片”的读取链路不重复讲剪贴板导入或 Move/Reference 的全部写入语义也不宣称未完成的 10 MiB 真机性能压力数据。持久预览的完成定义文档正文只保存标准 Markdown 相对链接例如![图](assets/image.png)或文档专属目录。资源字节存在用户可管理目录不嵌入正文 Data URL。关闭应用、重新打开同一文档后预览请求该相对路径原生读取实际文件图片再次显示。完成还包含失败语义未保存文档不能读取相邻资源绝对路径、跨级..、反斜线、query、fragment 和非图片扩展被拒绝文件超过 10 MiB 或读取期间变化被拒绝切换标签后旧请求不能污染新会话分块不完整、Base64 非法或字节长度不符不能创建 Blob旧 Object URL 必须回收。这个定义把“看见图片”提升为可迁移、可重启、可限制和可清理的本地资源能力。为什么不能把文件 URI 直接交给 ArkWebArkWeb 页面运行 marked、DOMPurify、命令面板和编辑器逻辑。即使内容离线也应按不可信展示层限制权限。若页面可直接读取任意file://或系统 URI恶意 Markdown 可能尝试引用用户其他文件预览就变成路径探测器。系统 URI 还可能需要 DocumentViewPicker 授权和平台文件服务解析不是普通浏览器 URL。直接写进 img src 既不可靠也会把原生能力泄漏给 Web。CSP 与 DOMPurify只能净化 DOM不能替代文件授权边界。因此 Web 只能提交 requestId、sessionId 和相对路径ArkTS 保留 documentUri 与授权 parentUri。文件服务验证目录后读取字节Web 永远不知道绝对路径。Bridge 传输的是有限图片数据不是开放文件 API。相对路径只允许两段AssetService.validateAssetReadRequest先验证请求标识与文档 session 格式再限制路径长度和字符。路径必须恰好两段第一段只能是assets或当前文档名推导的专属 assets 目录第二段是受支持图片文件名。functionvalidateAssetReadRequest(documentName:string,request:AssetReadRequest):Arraystring{if(!/^asset-read-[0-9]-[0-9]$/.test(request.requestId)||request.requestId.length72||!/^document-session-[0-9]$/.test(request.sessionId)){thrownewError(The local image request identifier is invalid.);}if(request.relativePath.length0||request.relativePath.length512||request.relativePath.includes(\\)||request.relativePath.includes(?)||request.relativePath.includes(#)){thrownewError(The local image path is invalid.);}constsegmentsrequest.relativePath.split(/);if(segments.length!2||segments.some((segment)segment.length0||segment.||segment..)){thrownewError(The local image path must stay inside one asset directory.);}constdocumentAssetscreateAssetDirectoryName(documentName,AssetDirectoryRule.DOCUMENT_ASSETS);if(segments[0]!assetssegments[0]!documentAssets){thrownewError(The local image path is outside the configured asset directories.);}mimeTypeForFileName(segments[1]);returnsegments;}恰好两段比先 normalize 再判断前缀更易审计。assets/../secret有三段并含..直接拒绝assets/a.png?x与 fragment 也拒绝避免同一路径产生不同缓存键或绕过扩展判断。mimeTypeForFileName白名单扩展不把任意文本当图像。文档名决定专属资源目录OhMarkdown 支持共享assets和文档专属目录两种规则。专属目录由当前 Markdown 文档名安全推导例如notes.md对应一个受管理名称。读取时不相信 Markdown 自己声明任意目录而是重新根据documentName计算允许值。这样把文档从一个工作区复制到另一个目录时相对资源结构仍可迁移同时阻止链接跨到 sibling 目录。共享 assets 适合多文档共用图片文档专属目录降低重名和误引用。设置只选择导入默认目录不扩大读取白名单。读取同时接受两种受管理目录是为了已经写入的文档在用户改变设置后仍能预览。设置影响未来写入不应让旧链接突然失效。原生读取使用 NOFOLLOW 与完整长度检查验证路径结构后原生根据已授权文档 parentUri 创建目录和文件 URI以READ_ONLY | NOFOLLOW打开。NOFOLLOW 避免最终图片项通过符号链接跳到受管理目录外。exportasyncfunctionreadAsset(documentUri:string,documentName:string,request:AssetReadRequest,authorizedParentUri:string):PromiseReadAsset{if(documentUri.length0){thrownewError(Save the document before loading a local image.);}constsegmentsvalidateAssetReadRequest(documentName,request);constdirectoryUricreateChildUri(getDocumentParentUri(documentUri,authorizedParentUri),segments[0]);constassetUricreateChildUri(directoryUri,segments[1]);constfileawaitfileIo.open(assetUri,fileIo.OpenMode.READ_ONLY|fileIo.OpenMode.NOFOLLOW);try{conststatawaitfileIo.stat(file.fd);if(stat.size0||stat.sizeMAX_IMPORTED_ASSET_BYTES){thrownewError(The local image exceeds the 10 MB preview limit.);}constcontentnewArrayBuffer(stat.size);constbytesReadawaitfileIo.read(file.fd,content,{length:stat.size});if(bytesRead!stat.size){thrownewError(The local image changed while it was being read.);}return{requestId:request.requestId,sessionId:request.sessionId,relativePath:request.relativePath,mimeType:mimeTypeForFileName(segments[1]),base64:BASE64_HELPER.encodeToStringSync(newUint8Array(content)),byteLength:bytesRead};}finally{awaitfileIo.close(file);}}先 stat 再一次完整读取随后核对 bytesRead。文件在读取时截断会失败不把部分字节当合法图像。finally 始终关闭 fd。当前最大 10 MiB 与导入限制一致避免预览读取比写入允许更大的资源。未保存文档没有安全父目录Untitled 文档尚无稳定 documentUri无法定义“相邻 assets”是谁。服务直接要求先保存而不是猜工作区根或写应用沙箱。这样 Markdown 相对链接的基准始终清楚。如果文档通过工作区枚举打开authorizedParentUri来自已授权条目如果是可解析本地路径则由 documentUri 获取父目录。Web 无法提交 parentUri避免自行选择权限边界。错误会返回预览不可用状态但不修改正文链接。用户保存文档或恢复资源后可以再次渲染失败不应删除 Markdown 内容。sessionId 阻止跨标签污染多文档编辑器中图片请求发出后用户可能切换标签。请求携带document-session-NWorkspaceShell 在读取前和传回前都比较activeDocumentSessionId。旧会话结果不能应用到新标签。privateasyncreadEditorAsset(payload:string):Promisevoid{constrequestJSON.parse(payload)asAssetReadRequest;constsessionIdtypeofrequest.sessionIdstring?request.sessionId:;if(sessionId!this.activeDocumentSessionId){thrownewError(The document session changed before the local image was loaded.);}constassetawaitreadAsset(this.documentUri,this.documentName,request,this.getActiveWorkspaceParentUri());if(sessionIdthis.activeDocumentSessionId){awaitthis.completeEditorAssetRead(asset);}else{this.failEditorAssetRead(asset.requestId,sessionId,asset.relativePath,The document session changed before the local image was loaded.);}}双重检查覆盖异步文件 I/O 窗口。只在开始比较不足读取 10 MiB 期间完全可能切换标签。失败回传也携带原 request/session/pathWeb 只标记对应图片。session 格式受正则限制不接受任意长字符串。请求 payload 还限制 2048 字符JSON 解析失败走统一错误路径。为什么要分块通过 runJavaScriptAssetService 返回 Base64若一次拼进巨型 JavaScript 字符串10 MiB 图片会膨胀到约 13.3 MiB单次 Bridge 调用和脚本解析压力过大。WorkspaceShell 使用 64 KiB 字符块先 begin 声明元数据逐块 append最后 finish。constASSET_READ_TRANSFER_CHUNK_CHARACTERS:number64*1024;awaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.beginAssetRead(${JSON.stringify(asset.requestId)},${JSON.stringify(asset.sessionId)},${JSON.stringify(asset.relativePath)},${JSON.stringify(asset.mimeType)},${asset.byteLength},${asset.base64.length}));for(letoffset0;offsetasset.base64.length;offsetASSET_READ_TRANSFER_CHUNK_CHARACTERS){constchunkasset.base64.slice(offset,offsetASSET_READ_TRANSFER_CHUNK_CHARACTERS);awaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.appendAssetReadChunk(${JSON.stringify(asset.requestId)},${JSON.stringify(chunk)}));}awaitthis.editorController.runJavaScript(window.OhMarkdownEditor?.finishAssetRead(${JSON.stringify(asset.requestId)}));每次 await 保证顺序requestId 关联同一接收状态。参数都用 JSON.stringifyBase64 和路径不会作为代码片段解释。分块不能减少 Base64 总内存但降低单脚本峰值与调用风险。Web begin 阶段先验证声明beginAssetRead不立即信任原生。它检查 pending 请求是否存在、session/path 是否一致、MIME 是否受支持、Base64 总字符数是否为合法四的倍数、byteLength 是否在 10 MiB 内。任何条件失败都完成并释放 pending。functionbeginAssetRead(requestId:string,sessionId:string,relativePath:string,mimeType:string,byteLength:number,totalBase64Characters:number):void{constpendingpendingAssetReads.get(requestId);constmaximumMath.ceil(MAX_IMPORTED_ASSET_BYTES/3)*44;if(!pending||pending.sessionId!sessionId||pending.relativePath!relativePath||!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)||!Number.isInteger(totalBase64Characters)||totalBase64Characters0||totalBase64Charactersmaximum||totalBase64Characters%4!0||!Number.isInteger(byteLength)||byteLength0||byteLengthMAX_IMPORTED_ASSET_BYTES){constrejectedcompletePendingAssetRead(requestId);if(rejected)releaseRequestedAssetRead(rejected.sessionId,rejected.relativePath);return;}receivingAssetReads.set(requestId,{sessionId,relativePath,mimeType,byteLength,totalBase64Characters,receivedBase64Characters:0,chunks:[]});}Bridge 双方都验证并非不信任自家代码而是保护协议边界和未来变更。原生 bug 或乱序调用不会让 Web 无限分配数组。append 与 finish 保证流完整每块不能为空不能超过单块上限字符只能是 Base64累计不能超过声明。finish 要求累计字符数完全等于声明pending/session/path 再次一致。少一块、多一块、错 requestId 都不会创建图片。创建 Blob 时按四字符边界解码每块末尾余数用 carry 拼到下一块最终 carry 必须为空。解码总字节数必须等于原生声明的byteLength。functionfinishAssetRead(requestId:string):void{constreceivingreceivingAssetReads.get(requestId);if(!receiving||receiving.receivedBase64Characters!receiving.totalBase64Characters){constrejectedcompletePendingAssetRead(requestId);if(rejected){releaseRequestedAssetRead(rejected.sessionId,rejected.relativePath);}return;}constpendingcompletePendingAssetRead(requestId);if(!pending||pending.sessionId!receiving.sessionId||pending.relativePath!receiving.relativePath){return;}constpreviewUrlcreateBase64ObjectUrl(receiving.chunks,receiving.mimeType,receiving.byteLength);storeLocalAssetPreview(encodeMarkdownAssetPath(receiving.relativePath),previewUrl,receiving.byteLength,true,receiving.sessionId);}长度一致不能证明图片语义有效但 MIME 扩展白名单、Base64 规则和浏览器解码共同构成当前边界。更强魔数检测可在原生读取时补充当前导入路径已有 MIME/扩展约束。Blob URL 只存在于当前进程Web 将解码后的 ArrayBuffer parts 组成 Blob再URL.createObjectURL。Markdown 正文仍是相对路径Blob URL 只用于当前预览 DOM不写回文档。应用重启后重新请求文件并生成新 URL。这种设计兼顾迁移性与浏览器渲染文档可以被其他 Markdown 工具按相对链接理解ArkWeb 得到可显示的安全对象 URL却不知道用户文件路径。缓存项记录字节长度、是否持久资源和 sessionId。切换或关闭 session 时应撤销不再使用的 Object URL防止长时间打开大量图片积累内存。Playwright 覆盖 Blob URL 回收和会话切换。请求队列与重复资源预览渲染可能同时发现多个本地图片。Web 将请求排队并限制活动读取避免一次向 Bridge 发大量 10 MiB 请求。相同 session/path 已有缓存时复用不重复读文件正在请求时也应去重。资源路径经过 Markdown 编码规范化后作为预览映射键。query 和 fragment 在原生验证前已拒绝避免同一文件绕过缓存形成多份 Blob。sessionId 隔离同名相对路径不让两个文档互相引用内存对象。缓存不是持久事实。磁盘图片外部变化时当前版本不会主动指纹轮询所有资源重新渲染或重开才读取最新字节。对文档正文已有外部修改检测图片资源监控仍是后续方向。失败时保持 Markdown 不变读取失败调用failAssetRead只查找 src 与编码路径匹配的 img设置data-local-asset-unavailable和 title。它不删除 Markdown 链接也不把失败消息插入正文。functionfailAssetRead(requestId:string,sessionId:string,relativePath:string,message:string):void{constpendingcompletePendingAssetRead(requestId);if(pending){releaseRequestedAssetRead(pending.sessionId,pending.relativePath);}if(!pending||pending.sessionId!sessionId||pending.relativePath!relativePath||sessionId!activeSessionId){return;}constencodedPathencodeMarkdownAssetPath(relativePath);preview.querySelectorAllHTMLImageElement(img).forEach((image){if((image.getAttribute(src)??)encodedPath){image.dataset.localAssetUnavailabletrue;image.titlemessage||getEditorMessages().localImageUnavailable;}});}正文是用户事实预览是派生状态。资源暂时缺失、权限变化或读取超时不应篡改正文。用户找回图片后链接仍在下一次渲染可以恢复。错误 title 随运行时语言设置更新默认文案但底层详细 message 保留诊断信息。绝对路径不应进入 Web 消息原生错误以受限相对路径为上下文。真实重新打开截图下面截图来自 HarmonyOS MateBook Pro 2in1 模拟器。图片先通过安全粘贴落入资源目录并插入相对链接保存文档、关闭并重新打开后预览再次显示同一图片。这次显示来自原生读取与分块 Blob 链路而不是最初剪贴板临时 URL。截图中源码保持标准 Markdown相对资源可以随文档目录迁移。设备同时验证资源文件真实存在、字节可读和重启后预览恢复。它不能单独证明所有越界拒绝因此还需要单元、ohosTest 与 Playwright。自动化与设备测试Playwright 覆盖持久预览请求、begin/append/finish 分块、失败不改正文、会话切换、Blob URL 缓存与回收。ArkTS 单元测试覆盖目录规则、路径和模式解析。ohosTest 在设备文件系统写入真实图片字节、执行重名与读取最终 MateBook Pro 2in1 模拟器7/7。当前全量 Web 测试为30/30。最终 Debug HAP 大小 1,520,352 字节SHA-256367ab8650479aa1fa8fe73bd1ebadd9a53f46659c850c2e388fc799d5cb88e5bohosTest HAP 大小 2,360,824 字节SHA-256b7230037b51044fe16168d2c835fb891e1c70f675941a1046165bc895217592c。产物未签名。浏览器自动化验证协议状态机ohosTest 验证真实 fileIo人工模拟器验证系统剪贴板、保存、重开和可见预览。10 MiB 极限、长时间多图缓存和真机内存压力仍需 G3 质量阶段测量。性能与内存预算Base64 会比原字节膨胀约三分之一原生同时持有 ArrayBuffer 与 Base64Web 接收 chunks 后再解码为 ArrayBuffer parts。10 MiB 上限控制单资源峰值但多图并发仍可能放大内存因此请求队列和 URL 回收重要。64 KiB 字符块降低单次 runJavaScript 负担不减少总传输。未来平台若提供更直接二进制通道可替代 Base64在当前架构中分块协议比一次巨型脚本更可控。预览只读取实际可见或渲染发现的受管理图片不扫描整个工作区。大文档模式会限制预览能力避免在超大文本中同时解析和加载大量资源。性能结论应以真机轨迹为准当前只确认功能和边界。安全威胁复盘路径遍历由两段结构、dot 拒绝、反斜线/query/fragment 拒绝和目录白名单防护符号链接由 NOFOLLOW 防护大文件由 10 MiB stat 与 Base64 上限防护协议注入由 request 格式、JSON.stringify 和字符白名单防护跨标签污染由 session 双检防护不完整流由字符数和字节数核对防护。Web 无法选择 documentUri、parentUri 或任意文件 API。Blob URL 只代表已验证图片字节CSP 与 DOMPurify 继续保护预览 DOM。图片解码器本身仍属于平台攻击面严格 MIME、大小和受管理目录减少输入范围但不能宣称消除所有恶意图片风险。已知限制与后续演进当前不监控磁盘图片外部变化不解析 EXIF不做缩略图不压缩原图不支持 SVG 等高风险类型也没有跨文档全局缓存。10 MiB 是硬上限真机多图峰值尚未形成性能报告。Base64 Bridge 有内存开销可探索 ArrayBuffer 通道或原生安全资源映射。缓存可增加总字节预算和 LRU避免长会话打开许多大图。魔数检测与解码失败结构化错误也可增强。这些方向都不能破坏现有不变量正文只存相对链接Web 不获文件权限请求绑定 session失败不改正文URL 可回收重开能够重新读取。结论OhMarkdown 持久图片预览把一次性 Blob 体验变成可重启本地资源链路Web 发现受管理相对路径ArkTS 验证两段目录和当前 session以 NOFOLLOW 读取完整有限字节64 KiB 分块传输Web 再次验证声明、流长度与字节数并创建可回收 Blob URL。真实模拟器已经证明保存并重新打开后图片仍显示Playwright、ohosTest 与 HAP 构建覆盖协议和文件路径。它的产品优势不是“支持图片”而是让图片与 Markdown 一起可迁移同时不给 ArkWeb 任意文件读取能力并把失败留在预览层而不是污染用户正文。