· compliance· 约 19 分钟精读

Offline Report Egress Carrier Filename Boundary — Technical Analysis | KeyRotate Technology

核心要点速览 · TL;DR 概要

端侧生成不等于端侧留存。同一份合规报告,TagWorks 先写进临时目录再分享文件 URL,SlingGuard 直接把 PDF 字节交给系统面板,设备标签则以位图进入打印与分享。本文按三条真实代码路径核对载体、文件名、类型声明与外发之后的残留,说明“数据不出设备”需要一个更准确的表述。

Offline Report Egress Carrier Filename Boundary — Technical Analysis | KeyRotate Technology

地下配电间没有信号。检验员把最后一台角磨机的读数敲进平板,等自动判定的绿色标记亮起,点右上角的分享图标。系统面板从底部升起来,第一行是提醒事项,第二行是拷贝与“存储到文件”。他选了后者,给文件挑了一个位置,面板收回,屏幕回到台账列表。

从这一刻起,这份报告不再属于这台平板。它可能进了甲方的共享盘,可能被转发到项目群,也可能只是留在本机的“文件”App 里,等着下次同步到某个云盘。App 对此一无所知,也不需要知道。它已经完成了自己的工作:把本机记录渲染成一份可以离开的文档。

端侧架构的讨论通常停在“数据不出设备”这句话上。这句话在生成之前成立,在点击分享之后就不成立了。中间那一下,是整个系统里唯一没有代码覆盖的段落——它由 iOS 的系统面板接管,由操作者决定去向。本文把这一段单独拿出来,称为外发面,并按 TagWorks 与 SlingGuard 的真实代码路径,核对字节以什么形状离开、带走了什么名字、之后留下什么。

一、先给外发面划一条线

外发面指 App 能把字节交给另一个主体的所有通道。在这个产品矩阵里,实际被用到的只有三类载体,外加一条打印通道:

  • 文件 URL:先把字节写进沙盒的临时目录,再把 URL 交给系统分享面板。
  • 裸 Data:不落盘,直接把内存里的字节数组交给分享面板。
  • 位图对象:把渲染好的图像交给分享面板或打印控制器。

三者都经过系统框架,App 看不到接收方,也拿不到接收结果。差别不在“有没有外发”,而在外发之后:盘上有没有留下东西、接收方能不能从文件名读出信息、类型由谁声明、副本还能不能被检索。

这三件事决定了同一份报告在审计意义上的不同命运。

同一份报告,三种载体字节以什么形状离开 App,决定了它之后留下什么产物载体外发之后TagWorks-Report.pdf报告 · 固定文件名,不带日期URL文件 · 留在盘上同名再导出会替换前一份预览显旧内容,分享的是新字节SlingGuard 报告内存生成 · 一次也不落盘Data字节 · 无文件名面板关闭,内存即释放类型由接收方从字节魔数判断Tag-<ID>.pdf标签 · 标识直接拼进文件名UIImage位图 · 无文本层接收方重编码后不可检索打印任务名是唯一带标识处三条出口都没有回执:谁导出、带走了哪一版,系统里查不到。台账记录检验与放行,不记录导出;分享面板的结果不回到 App。

二、载体一:临时目录里的固定文件名

TagWorks 的报告导出是一条同步链路。按钮回调里调用 ReportExporter.makeReport,传入当前的资产、记录、公司名、检验员与许可证,函数用 UIGraphicsPDFRenderer 在 595×842 点的 A4 页面上画完封面与逐站点页,返回一个 Data。随后 present 把它写到临时目录:

tmp/TagWorks-Report.pdf

免费档的文件名换成 TagWorks-Report-sample.pdf,封面同时加盖斜向水印。写盘用 .atomic,再通过 ShareURL 触发预览页,预览页用 PDFDocument(url:) 打开同一个 URL,右上角的分享按钮把 URL 交给 UIActivityViewController。

这条链上有四个值得记录的细节。

第一,文件名是固定的。没有日期、没有站点、没有记录条数。同一天上午导一次、下午再导一次,两次写的是同一个路径。第二次导出会直接替换第一次的文件;如果第一次的预览页还开着,它持有的仍是同一个 URL,屏幕上的内容不会变,但磁盘上的字节已经换了。预览与分享之间没有快照隔离。

第二,写入失败是静默的。present 里的 do/catch 只做了一件事:catch 分支直接 return。磁盘满、路径非法、权限异常,操作者看到的都是“点了没反应”。

第三,工程里没有任何一处清理这些临时文件。整个 TagWorks 源码中,removeItem 只出现在四处:照片删除、签名删除、登记册重置、以及备份恢复时回滚照片。导出产生的报告、CSV、标签 PDF 与 PNG,一次也没有被删除过。

第四,present 接收一个 type: String 参数,三个调用点分别传 application/pdf、text/csv、text/csv,函数体里没有使用它。类型声明完全依赖文件扩展名,由系统在分享时推断。

CSV 走同一条路,但文件名同样是固定的:TagWorks-Register.csv 与 TagWorks-Test-History.csv。两者都在 gatedExport 后面,只有 Pro 用户能触发;报告导出与标签导出没有走这个闸门,免费用户可以导出带水印的报告,也可以导出带完整标识的标签。四条导出路径里,只有 CSV 被权益拦下。

三、载体二:不落盘的裸 Data

SlingGuard 的报告导出选了另一条路。ReportPreviewView 在 onAppear 时把资产、记录、放行记录与公司档案捕获成局部常量,丢到 DispatchQueue.global(qos: .userInitiated) 上生成 PDF,完成后回主队列赋值给 pdfData。分享时传给面板的是 [data],不是 URL。

ShareSheet(items: [data])

这意味着三件事同时成立:盘上没有文件;没有文件名与扩展名;面板关闭后,这份字节只被预览视图持有,视图销毁即释放。

代价是产物失去了身份。系统面板拿到的是一个 public.data 类型的 item provider,接收方只能从字节头部的 %PDF- 魔数判断这是 PDF。存到“文件”时用什么名字、邮件附件显示成什么,由系统或接收方决定,App 无法参与。

这条路径还有一个性能细节:预览视图的 updateUIView 用 uiView.document?.dataRepresentation() != data 来判断是否需要换文档。这个比较要先取得整份文档的数据表示,再与新数据逐字节比对。页面越多,每次 SwiftUI 更新付出的代价越大;它换来的是“数据变了才重绘”的正确性。

免费档在这里同样生成带水印的版本,onChange(of: pro.isPro) 会在权益状态变化时重新生成一次。也就是说,同一份报告在预览期间可能被生成两次,而两次之间台账没有变、内容也相同,只是水印不同。

四、载体三:位图与打印任务

设备标签是第三种形态。PrintableTagView 先用 TagWorksLabelGenerator.renderTagImage 把标签渲染成 UIImage,展示、打印、分享都用这张图。

打印走 UIPrintInteractionController:printInfo.outputType = .general,jobName = "TagWorks-\(asset.identifier)",printingItem = img。分享则先落盘成 PDF 或 PNG,文件名直接拼接资产标识:

Tag-<identifier>.pdf

这里的 <identifier> 是操作者输入或扫码得到的字符串。AssetIdentifier.canonicalize 只做三件事:去首尾空白、识别 TAGWORKS| 前缀、从管道分隔的字段里取 ID: 后的值。它不检查斜杠、冒号、控制字符,也不限制长度。若标识里含 /,appendingPathComponent 会构造出一个不存在的子路径,try? 把写入错误吞掉,导出按钮没有任何反馈。

位图载体的固有损失是文本层。接收方拿到的是一张图,设备编号不能被搜索、不能被复制、不能被辅助技术朗读。唯一还带着标识的地方是打印任务名,而它只存在于打印队列里,任务结束就消失了。

五、把三条通道放在一张表上

产物载体落点命名类型来源外发之后
TagWorks 报告文件 URL临时目录固定 TagWorks-Report.pdf扩展名留在盘上,同名覆盖
TagWorks 台账 CSV文件 URL临时目录固定 TagWorks-Register.csv扩展名留在盘上,同名覆盖
TagWorks 历史 CSV文件 URL临时目录固定 TagWorks-Test-History.csv扩展名留在盘上,同名覆盖
TagWorks 设备标签文件 URL临时目录Tag-<identifier>.pdf扩展名标识非法时静默失败
TagWorks 备份包文件 URL临时目录TagWorks-backup-<时间戳>.json扩展名带时间戳,互不覆盖
SlingGuard 报告裸 Data无无字节魔数面板关闭即释放
SlingGuard 台账 CSV文件 URL 两份临时目录带 ISO 时间戳扩展名留在盘上,只清引用
SlingGuard 备份文件 URL临时目录SlingGuard-Backup-<秒级时间戳>扩展名留在盘上
TagWorks 标签打印UIImage无打印任务名位图交给打印机
SlingGuard 标签分享UIImage无无位图接收方重编码

这张表里最刺眼的一行是备份包。备份用了时间戳,报告没有。备份服务显然考虑过“同名会覆盖”,报告导出没有;两段代码在同一个仓库里,对同一个问题做了相反的选择。

文件名是接收方唯一能看到的元数据。固定名把三样东西丢掉了:时间、范围、版本。TagWorks 的报告封面确实有 Report date,但那是 DateFormatter 的 dateStyle = .medium,只到日,没有时刻,也没有时区。同一天导出的两份报告,文件名相同、封面日期相同;要区分它们,只能打开正文逐页比对。

跨设备时问题更明显。两台平板各导一份报告,汇总到同一个文件夹,同名文件会互相覆盖,或者被迫改名成“副本”“副本 2”。改名之后,文件名与内容的对应关系就断了。

六、导出这个动作,没有被记录

台账记录了什么?检验、修复、复检、放行,每一条都有时间、操作者、证据与链接关系。TagWorks 甚至为失败项设计了四类记录与二十五项判断,确保一次放行必须挂在正确的复检上。SlingGuard 为隔离与放行建立了链式释放,缺一项证据就不算完成。

但没有任何一个模型,记录“这份报告被导出过”。

exportReport 只做生成与写盘;present 只设置 shareURL;ShareSheet 是 UIViewControllerRepresentable 的最简实现,makeUIViewController 里创建控制器,updateUIViewController 是空函数。没有 completionWithItemsHandler,没有回调,没有计数。

结果是四个问题都答不上来:谁导出的、什么时候导出的、导出的那一版包含多少条记录、导出的是不是水印版。

有意思的是,水印状态其实是可推断的,但推断方式很脆弱。present 里的 isSample 参数取的是 name.contains("sample")——从文件名字符串反推权益状态。渲染水印时用的是 !pro.isPro。两个判断不同源:如果哪天报告文件名改成 TagWorks-Report-free.pdf,水印还会照盖,但预览页底部的“样本”提示条会静默消失,因为文件名里没有 sample 这个词。

这是字符串约定替代类型约定的典型代价。它不会立刻出错,只会在某次改名后安静地错。

七、生成时点决定“这一版”是什么

两份报告都是读时投影,不是持久事实。

TagWorks 在按钮回调里同步生成,读的是当时内存中的 store.assets 与 store.records;生成过程在主线程,记录多、站点多时,界面会停顿到 PDF 数据返回。SlingGuard 在后台队列生成,读的是进入预览页那一刻捕获的快照,生成期间台账再变也不影响这一份。

两种做法都能自洽,但都缺少一个东西:这一版对应哪个水位。

导出之后继续录入,台账前进了,报告留在原地。三天后拿报告与台账对照,记录数对不上,没有人能证明报告是导出时的那一版,还是后来补打的一版。报告里没有记录条数、没有最后一条记录的标识、没有生成时刻(只有日期)。审计链在设备内部是完整的,一旦离开设备,链条就断在文件名上。

八、五条可以落地的改动

  1. 文件名带上身份。报告用 TagWorks-Report-20261106T0240Z-128.pdf,UTC 时刻加记录条数;标签文件名先对标识做白名单替换,只保留字母、数字、连字符与下划线,长度截到 40 字符。
  2. 预览与分享分离。预览用 Data 渲染,点分享时才落盘,分享面板 onDismiss 时删除临时文件。SlingGuard 的 CSV 分享已经写了 onDismiss,但只把数组清空,没有删文件;补一行删除即可。
  3. 给分享面板加回执。completionWithItemsHandler 只记两个布尔量:是否完成、是否取消。不记接收方,也不记活动类型——那会引入新的隐私面。有一点记录,比完全没有好。
  4. 导出事件进本地台账。字段只需要五个:时间、产物类型、记录条数、内容摘要的前八位、是否水印。摘要用 CryptoKit 的 SHA-256,不上传,不联网,只在本机留一条。
  5. 报告封面加一个“本次导出”区块。生成时刻精确到分、记录条数、照片张数、水印状态。这一块不改变任何判定,只是让纸质件与电子件能对上号。

这五条都不需要新服务、新权限或新存储格式。它们只是把已经存在于内存里的信息,写到文件名与一条本地记录里。

九、能力边界

  • 端侧能保证生成与留存,不能保证外发之后的控制。报告一旦离开沙盒,FileProtection、数据隔离、审计链都不再适用,副本的保管责任转移给接收方。
  • “数据不出设备”更准确的表述是:默认不出设备,没有自动上传,外发必须由操作者显式发起。前三句都能被代码验证,“外发之后仍然受控”不能。
  • 没有回执,就只能证明“没有自动外发”,不能证明“没有外发”。
  • 文件名不是防篡改手段。它只是身份线索;内容的真实性仍然依赖报告内的签名、摘要与台账。
  • 位图载体不可检索,这是格式的属性,不是实现的缺陷。要可检索,就得回到带文本层的 PDF。
  • 分享面板的接收方列表由系统与用户安装的应用决定,App 既不能枚举,也不能限制。

十、结语

出口的设计只需要回答三个问题:谁发起、带走什么、留下什么痕迹。

前两个问题,这两个应用都已经回答得相当清楚:操作者显式点击,字节以明确的载体离开,内容来自本机台账。第三个问题还没有答案。报告走了,台账里没有一行字说它走过。

补上这一行,不需要把端侧做成云端。它只需要在写文件的那几行代码旁边,再写一条记录。

参考资料

Share:
Back to Blog