☰
App-Store-Connect-CLI 本地公证票据 stapling 与校验:staple / validate 命令的安全设计解析
2026/9/29 6:13:58 网站建设 项目流程

【免费下载链接】App-Store-Connect-CLI

Fast, scriptable CLI for the App Store Connect API. Automate TestFlight, builds, submissions, signing, analytics, screenshots, subscriptions, and more

项目地址:https://gitcode.com/gh_mirrors/ap/App-Store-Connect-CLI
点击查看免费下载

导读

本文聚焦 App-Store-Connect-CLI 中asc notarization命令组新增的两个纯本地 macOS 工件操作:staple(将 Apple 公证票据附加到本地工件并立即校验)与validate(仅校验、绝不改写工件)。这两个命令不触碰任何 App Store Connect 端点、不需要认证凭据,而是直接驱动 Apple 的xcrun stapler工具,并在命令层、运行层与内容绑定三层上建立了一套针对替换竞争(TOCTOU)与部分变更的防御体系。读完本文,你将掌握这两个命令的完整用法、参数语义、输出格式,以及其底层「探针分类 + 阶段验证器 + 有界目录清单/文件指纹」的安全设计原理。

命令面:在 Notary API 命令之外新增的本地操作

asc notarization命令组原本包含submit、status、log、list四个子命令,它们通过 Apple Notary API(internal/asc/notary.go中的/notary/v2/submissions端点、S3 上传、轮询等待等)完成公证提交流程,这部分行为保持不变。本次设计文档描述的是一次增量(additive)变更:为命令组追加两个仅在本机 macOS 上运行的工件操作,涉及新的调用形式如下:

asc notarization staple --file PATH --confirm [--output FORMAT] [--pretty] asc notarization validate --file PATH [--output FORMAT] [--pretty]

两个命令的核心语义:

  • staple:先调用 Apple 本地的xcrun stapler staple PATH,成功后紧接着调用xcrun stapler validate PATH,两者都成功才返回成功结果。由于 stapling 会就地改写工件(原地附加票据),因此强制要求显式--confirm确认标志。
  • validate:只调用xcrun stapler validate PATH这一校验操作,从不改写工件。

Apple 的 stapler 支持 UDIF 磁盘映像(.dmg)、已签名的 flat installer 包(.pkg)以及受支持的代码签名 bundle(.app)。ZIP 被有意拒绝作为直接目标:因为 stapling 需要就地修改其中包含的条目,ZIP 必须在 stapling 其中内容后重新打包。在命令实现(internal/cli/notarization/notarization.go)中,.zip扩展名在路径清洗后被直接判定为 usage 错误,并给出「staple the contained item and recreate the archive」的指引。

命令层在构造上还做了严格的参数校验,对应实现要点:

  • --file为必填;允许出现多次的重复--file会被singleStringValue拒绝(报 "specified multiple times")。
  • staple缺少--confirm时,在任何目标检查或工具调用之前即以 usage 错误退出。测试 cmd/notarization_staple_confirm_test.go 验证了这一点:传入一个不存在的missing.dmg且不带--confirm时,退出码为ExitUsage,stderr 只含 "--confirm is required",且不会出现 "does not exist" 字样——证明目标校验根本没有执行。
  • 两个命令均不接受位置参数;--output与--pretty的组合合法性由shared.ValidateOutputFormat在调用任何本地工具前校验。

目标校验:在工具与认证工作之前建立 usage/runtime 边界

设计文档强调:命令层在任何工具或认证工作之前校验调用与目标。这保证了staple在确认之前绝不检查目标、绝不调用 Apple 工具,把「需要确认」这个语义前置到一切副作用之前。

目标路径的处理规则(validateStaplerTargetDetails 及周边实现):

  • Trim 仅用于判空:strings.TrimSpace(pathValue) == ""才认为--file缺失;真实文件名中的首尾空白被保留。
  • 路径只做一次filepath.Clean清洗,然后解析为绝对路径。
  • 拒绝项:NUL 字节、缺失路径、最终组件符号链接、不安全的父级符号链接、特殊文件(FIFO、socket、设备等)、空普通文件。
  • 接受项:普通文件或目录 bundle,具体分类交给 Apple 工具。

最终组件的种类通过有根的 no-followLstat探针(rootfs.Root+Lstat,见 probeStaplerTargetKind)判定,只有被证明是普通文件时才回退到普通文件路径;探针发现的特殊文件直接作为无效输入拒绝;遍历失败与打开失败则保持「运行时(operational)」分类,而不会被当作普通文件重试。设计上明确要求:任何分类决策都不读取打开错误的文本内容——这样即使工件自身路径名包含诊断性短语(如 "not a directory"),也不会被误报为无效输入。

这一探针确立了全部语义前置条件,从而固定了 usage/runtime 边界:

  • 缺失路径、符号链接、特殊文件、直接.zip目标、空文件 →操作者输入错误(usage)。
  • 探针成功后,紧随其后的打开出现不一致——包括ENOENT、最终组件出现符号链接、种类翻转(kind flip)、或同类对象被替换——都属于替换竞争(replacement race),应保持运行时分类及其净化后的诊断,而不是把工件路径挂到「无效输入」上。

实现层面有几个值得注意的细节:

  • 工件描述符全程保留(validatedStaplerTarget.handle一直 open 到操作结束),保持工件 inode 被分配,攻击者 unlink 目标后无法让替换对象复用被回收的文件 ID 并满足os.SameFile对已记录身份的比对。
  • 保留的rootfs.Root只 pin 文件系统根,因此工件句柄需要被单独 pin。
  • 父级与最终检查复用仓库既有的 no-follow/rooted 文件系统辅助函数;macOS 的稳定别名/etc、/tmp、/var在卷边界被接受(映射为/private下的真实路径,见 staplerNoFollowPath),而所选工件父目录之下的符号链接被拒绝。
  • 相对路径解析时保留物理当前工作目录的打开描述符,并在每个阶段前重新校验 cwd 身份,因此 cwd 被重命名/替换时会在子进程拿到旧路径名之前被拒绝。
  • 路径作为单个 argv 元素传给子进程,绝不被插入到 shell 命令中——这从根上消除了带空格或 shell 元字符路径的注入风险。

本地运行器:internal/xcode 中的 stapler 封装

可复用的本地运行器位于internal/xcode(internal/xcode/stapler.go),其约束与行为:

  • 要求 Darwin 平台:ensureStaplerAvailable在非 macOS 上直接返回 "stapler is supported on macOS only" 错误。
  • 解析xcrun,并验证xcrun --find stapler成功;解析失败以StaplerResolutionError包装,其公开文本刻意封闭("stapler tool resolution failed"),避免泄露主机路径,原始查找原因通过Unwrap保留给内部调用者分类。
  • 复用仓库既有的命令构造与有界等待(bounded wait)接缝,包括xcodebuildErrorTailLimit有界诊断缓冲与xcodeCommandPipeWaitDelay。
  • 子进程 stdout/stderr 定向到调用方的诊断 writer(CLI 中是os.Stderr),保证结构化命令输出仍然可解析。
  • 上下文取消通过exec.CommandContext传播;不做任何 API 客户端或凭据查找。

运行器暴露两组入口:Staple/StapleWithVerifier与ValidateStaple/ValidateWithVerifier。带 verifier 的变体接收StaplerStageVerifier(func(operation StaplerOperation, before bool) error),它在每个子进程的前后、且在xcrun --find stapler解析完成之后立即运行:

  • staple 流程守护它的 staple 与 validation 两个子进程;
  • validate-only 流程守护它唯一的 validation 子进程。

这样做的目的是关闭「工具解析完成」与「子进程启动」之间的窗口:如果某个替换在子进程将要运行时仍然存在,它会在子进程启动前被拒绝,而不是让 Apple 工具检查一个工件、命令却报告另一个。文档同时坦率说明其局限:若替换在 pre-child 检查之前被完全还原,包装器无法探测到,运行仍可能被报告为已验证——但此时子进程观察到的是原始工件,报告的状态与实际被检查的对象一致。验证器失败会返回StaplerStageVerificationError这一类型化阶段错误,调用方仍可通过Unwrap取得原始 cause 进行分类。

阶段验证:把「被替换」与「无法评估」分开

设计文档为每个阶段边界定义了两种性质不同的失败:

  • 被证明的替换(proven replacement):SameFile不匹配、目标消失、种类翻转、被符号链接替换 → 报告为 "artifact target changed",并点名具体阶段(before/after stapling、before/after validation)。
  • 无法评估的边界:打开或 stat 失败属于操作性故障,例如权限被撤销、描述符耗尽、I/O 错误 → 报告净化后的文件系统诊断,因为纠正动作不同。

这两类结果都不可能产生成功结果。在命令层,对应实现为staplerTargetIdentityError(目标已改变,点名阶段)与staplerTargetVerifyError(无法检查文件系统)两个私有类型,以及 classifyStageOpenFailure 中的分类逻辑:它通过重新探针最终组件(而非读错误文本)来区分「目标消失/种类翻转/被符号链接替换」与「无法检查文件系统」。

CLI 的错误报告函数reportStaplerFailure与reportStaplerTargetStageFailure将这两类错误映射为稳定、净化后的 stderr 诊断:

  • 已启动的 staple 子进程失败:由于子进程可能已写入部分工件,且 post-stage 检查把「重新捕获的状态」作为下一基线(而非与 staple 前证据比对),任何从已启动 staple 子进程而来的失败都被归类为可能的局部变更:点名失败阶段,警告工件可能已被修改但未经验证,并返回子进程退出码。
  • 子进程启动前的失败:保持普通错误。
  • staple 成功但后续 validation 失败:专门报告为「未经验证的变更」,并返回 validation 子进程的退出码。
  • 解析、平台、取消类失败保持普通通用运行时映射;解析器启动失败叠加迟到取消时,保留其 resolution 阶段分类并附带 context 错误。
  • 启动或信号失败由类型化运行器错误保留内部 cause,但只对外发出稳定的阶段诊断,不暴露操作系统给出的可执行文件或临时路径。
  • 非 NotFound 的xcrun查找失败使用同样的封闭 resolution 阶段诊断,内部保留查找 cause。
  • 若 staple 子进程已成功完成但其诊断 writer 失败,运行器返回 writer/process cause 作为局部变更失败,而不会声称「未经验证的成功结果」。

内容绑定:目录清单与普通文件指纹

仅靠 inode 身份并不能捕获「同 inode 的原地重写」。因此命令在两个维度上增加了有界内容绑定。

目录 bundle:确定性 SHA-256 清单

对目录 bundle,命令在命令边界捕获一份私有清单(private inventory)(internal/cli/notarization/stapler_inventory.go):

  • staple 流程:在 staple 子进程成功后、后续 validation 子进程前捕获;validation-only 流程在其子进程前立即捕获。
  • 清单是一个确定性 SHA-256 摘要,覆盖每个相对条目的:种类(kind)、规范化相对路径、权限 mode、大小、文件 SHA-256,连同总字节数与条目数。
  • 扫描器扎根于已 pin 的文件系统根,打开目录与文件时不跟随最终符号链接;包含的相对符号链接被接受并记录为链接条目(原始目标长度 + 目标文本 SHA-256);绝对路径或词法逃逸(..逃出根)的链接被拒绝,扫描器绝不跟随任何链接目标;特殊文件保持拒绝。
  • 每个条目在读完后被重新检查身份、大小、mode(链接还检查目标文本是否未变)。
  • 条目之间检查活动 context(取消传播);读取在有界路径、有界条目数、有界总字节数下失败关闭(fail closed)。

具体的上限常量在源码中明确可查:

常量值含义
staplerInventoryMaxBytes32 << 30(32 GiB)清单/指纹总字节上限
staplerInventoryMaxEntries250,000最大条目数
staplerInventoryMaxPath4,096单条相对路径上限
staplerInventoryReadBatchSize256单批 Readdirnames 数量(防恶意目录无界分配)
  • validation 之后必须再捕获第二份清单;两份清单任何不一致都视为未经验证的工件变更,不可能产生成功。
  • 清单只是比较证据:路径、名称、摘要与原始扫描 cause 永不进入公开输出或遥测。扩展属性(xattrs)与文件系统 ACL 不在该内容摘要范围内,也不被它表示为「未变更」。

普通文件:有界大小 + SHA-256 指纹

普通文件目标在每个阶段边界获得同样的有界字节绑定(staplerRegularFileFingerprint,含 SHA-256 摘要与大小):

  • staple 流程:stapling 后捕获指纹,在后续 validation 前后比对;
  • validate-only 流程:validation 前捕获、之后比对。

因此同 inode 重写无法仅仅因为保留了外层 device/inode 身份而通过校验。普通文件指纹同样是私有比较证据,绝不序列化或暴露,且不是回滚或原子性保证。

输出与渲染:成功结果的结构化表示

成功计算出的输出由internal/asc/output_notary.go中导出的结构体表示,并注册进常规输出注册表(internal/asc/output_registry_init.go):

staple成功输出:

{ "filePath": "/absolute/path/MyApp.dmg", "operation": "staple", "stapled": true, "validated": true }

validate成功输出:

{ "filePath": "/absolute/path/MyApp.dmg", "operation": "validate", "validated": true }
  • JSON 写到 stdout;table 与 Markdown 渲染同样的阶段状态(表头分别为 Operation / File Path / Stapled / Validated 与 Operation / File Path / Validated,见 notarizationStapleResultRows)。
  • 进度、子进程诊断与纠正性指引写到 stderr。
  • 失败操作绝不输出成功结果。

兼容性与范围

这是纯增量行为:既有 Notary API 命令、轮询、输出形态、认证与遥测全部保持不变。新命令不做以下事情:解压 ZIP、提交工件、重新签名、打包、上传、去除票据(un-staple)或运行 Gatekeeper 策略评估。Apple 的 stapler 对两个操作都可能需要网络访问,但 CLI 自身不解析 App Store Connect 认证——这正是不复用 Notary API 客户端(否则会把凭据与服务器行为引入纯本地操作)的设计决策的直接体现。

测试策略与发布验证

设计文档给出明确的 RED-GREEN 验证矩阵:

  • CLI usage 与输出用例:必填/空--file、缺失--confirm(含 usage 退出码且无任何目标/工具工作,由 cmd/notarization_staple_confirm_test.go 落地)、位置参数、直接 ZIP 拒绝、非法 output/pretty 组合、help 可发现性、JSON/table/Markdown 渲染、既有命令不变。
  • 运行器测试:精确 argv、路径保留、工具解析、stdout/stderr 路由、子进程状态保留、staple-then-validate 顺序、validate-only 行为、取消、不支持的主机、缺失工具。
  • 文件系统测试:最终与父级符号链接、缺失/特殊/空/普通文件/目录 bundle 目标;目录 bundle 额外覆盖 validation 前后的嵌套替换、清单稳定性、符号链接、特殊文件、扫描中取消、entry/byte/path 边界。
  • 信号终止:staple 子进程启动后的信号终止被归类为局部变更,保留内部进程 cause,只发出未经验证警告。

聚焦测试通过后,用构建出的二进制验证 help、输出流与 usage 状态,并跑完整门禁:make build、make format、make generate-command-docs、make check-docs、make lint、ASC_BYPASS_KEYCHAIN=1 make test。在 macOS 上,使用现有已 Accepted、Developer ID 签名工件的可丢弃副本依次运行 staple 与 validate 做冒烟测试;不要创建新的公证提交。

发布要求:实现与生成的命令文档作为增量变更提交并推送,不重写特性分支上的既有提交;发布前重跑聚焦测试与构建/格式化/文档/lint/全量测试门禁;在有可丢弃工件时,必须执行 macOS 冒烟测试。

备选方案与已知局限

设计文档记录的备选方案对比:

  • 只在 API 面向的命令中保留操作 → 最终分发的工件无法验证;
  • 通过 shell 调用 stapler → 带空格或 shell 元字符的路径不安全;
  • 复用 Notary API 客户端 → 给纯本地操作引入凭据与服务器行为;
  • 最终选择:直接xcrunargv 运行器,保持边界窄小,同时复用仓库既有的 macOS 命令与进程测试接缝。

明确承认的残余风险:

  • 自动化套件使用确定性命令接缝,无法证明 Apple 票据服务行为,也无法证明每种受支持的 bundle/磁盘映像/包格式下 stapler 的精确结果。
  • 若 Apple 在取消前完成了就地 staple,或后续 validation 失败,没有回滚机制:命令只报告该状态,不恢复工件。
  • ZIP 解压、重新打包与 Gatekeeper 评估在本次变更之外,需要独立工作流。
  • 包装器在每个本地阶段锚定已校验目标身份,但 Apple 的 stapler 接收的是路径名而非包装器的打开描述符:并发替换仍可能发生在身份检查之后、子进程之中。包装器在阶段边界检测替换,有界目录/普通文件绑定检测边界之间的内容变化,但两者都无法消除子进程运行期间 provider/path 型 TOCTOU 窗口。普通文件指纹是比较证据,不是回滚或原子性保证。

理解这层设计边界,有助于你在自动化发布流水线中正确使用asc notarization staple --confirm与asc notarization validate:它们是「本地、纯 macOS、无认证依赖、可脚本化」的票据附加与核验命令,其价值不仅在于一步完成 staple+validate,更在于把「目标被并发替换」「工件被部分修改但未验证」这类原本难以察觉的状态变成可诊断、可分类、绝不误报成功的确定性结果。

【免费下载链接】App-Store-Connect-CLI

Fast, scriptable CLI for the App Store Connect API. Automate TestFlight, builds, submissions, signing, analytics, screenshots, subscriptions, and more

项目地址:https://gitcode.com/gh_mirrors/ap/App-Store-Connect-CLI
点击查看免费下载
上一篇:The Concise TypeScript Book:深入解析交叉类型(Intersection Types)与 `&` 运算符
下一篇:LaWGPT API接口开发终极指南:构建专业法律AI服务的完整方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询