手脚与护栏:工具、执行、沙箱、编辑、MCP/skills
30 秒导读: 前几章讲的是「怎么把模型的话说得漂亮」(harness 仿真、请求塑形、回合循环)。这一章讲模型的话怎么真的落到你的机器上。README 里那句「Runs commands inside native sandboxing on macOS, Linux, and Windows」就是本章的全部主题:一个动作从「模型吐出一段 JSON」到「一条命令在受限沙箱里真的跑起来、一个文件真的被改」,中间要过声明、路由、审批、护栏、沙箱、模糊匹配六道关。
1. 这层是什么(零基础也能懂)
一句话定义: 这是 agent 的手脚(能真的动手改机器)加护栏(动手前后有安全闸)。
模型本身只会做一件事:输出文字。它说「我要运行 rm -rf build/」或「把 foo.py 第 12 行改成 x = 2」,这些都只是字符串。真正让字符串变成硬盘上的字节变化、进程表里的一个 PID,是这一层的活。
它要解决的核心难点,不是「调用模型」,而是这三件事叠在一起:
- 把话翻译成动作 —— 模型说的工具名/参数,要能对上一个真实可执行的运行时。
- 别把机器搞坏 —— 危险命令要拦、要问用户、要关进沙箱;不能让一次幻觉把家目录删了。
- 容错落地 —— 模型给的「旧代码」几乎从不和磁盘上的真实内容一字不差(空格、引号、换行都可能差),补丁还得能打上去。
给谁用: 这一层是 codex-core 的内部机制,终端用户感知不到;但对读源码想搞懂「agent 如何安全执行」的工程师,这是最硬核、工程含量最高的一支。
五个子系统一览(本章逐个讲):
| 子系统 | 干什么 | 主要位置 |
|---|---|---|
| 工具体系 | 声明工具、发现工具、把工具喂给模型、分发调用 | tools crate + core/src/tools/ |
| 命令执行 | 把 argv 真的 spawn 出去,带超时/输出上限 | core/src/exec.rs、exec_env.rs |
| 执行策略 | 命令跑之前判定:放行 / 问用户 / 禁止 | core/src/exec_policy.rs + execpolicy crate |
| 原生沙箱 | 三大平台各自的进程隔离(文件+网络) | sandboxing crate + linux-sandbox crate |
| 文件编辑 | 把模型给的补丁模糊匹配后打到文件上 | core/src/apply_patch.rs + apply-patch crate |
| 扩展面 | 接外部工具(MCP)、技能(skills)、钩子(hooks)、复核(guardian) | core/src/mcp*.rs、core-skills、hooks、guardian |
一句话直觉: 把这一层想成餐厅后厨的传菜+食品安全系统。模型是前台点单员(只会喊单),这一层是把单子翻译成后厨动作、检查有没有过敏原、把危险操作交给经理审批、最后真的把菜做出来的那套流程。
本节到此为止,不碰代码;下面开始进入机制。
2. 顶层全景(一次动作怎么落地)
怎么读这张图: 从上到下是一个工具调用的生命周期;左边是「声明期」(回合开始时,把工具目录准备好喂给模型),右边是「执行期」(模型真的调了某个工具之后发生什么)。命中护栏就可能停在审批那一步。
┌──────────────── 声明期(回合开始前) ─── ─────────────┐
│ │
各来源工具 │ ToolExecutor::spec() ──► ToolSpec │
(shell/apply_patch/ │ │ │ │
mcp/skills/plugin) │ ▼ ▼ │
│ ToolExposure 分级 create_tools_json │
│ Direct/Deferred/Hidden (Responses API JSON) │
│ │ │ │
│ └── Deferred 的先藏起来 ─┘ ──► 喂给模型 │
└──────────────────────────────────────────────────────┘
│
模型回一条 tool call ▼
┌──────────────────────── 执行期 ────────────────────────┐
│ │
│ ToolRouter.dispatch ──► 找到对应 ToolExecutor │
│ │ │
│ ┌─────────┴─────────┐ │
│ 命令类(shell) 编辑类(apply_patch) │
│ │ │ │
│ exec_policy 判定 assess_patch_safety │
│ 放行/问用户/禁止 放行/问用户/拒绝 │
│ │ │ │
│ guardian 复核 ◄─────────────┘ (可选 LLM 护栏)│
│ │ │
│ ▼ │
│ SandboxManager.transform (选沙箱+改写 argv) │
│ │ │
│ ┌─────────────┼──────────────┐ │
│ seatbelt linux helper windows token │
│ (macOS) (bwrap+seccomp) (受限令牌) │
│ │ │
│ ▼ │
│ execute_env → spawn 真进程 → 回执给模型 │
└──────────────────────────────────────────────────────────┘
部件一句话职责:
| 部件 | 干什么 | 真实符号 / 位置 |
|---|---|---|
ToolExecutor trait | 每个工具都实现它:给出 spec、暴露级别、handle() 执行 | tools/src/tool_executor.rs:49 |
ToolExposure | 决定工具喂给模型还是先藏(延迟加载) | tools/src/tool_executor.rs:15 |
ToolRouter | 把模型的 tool call 路由到对应运行时并分发 | core/src/tools/router.rs:35 |
exec_policy | 命令执行前的规则+启发式判定 | core/src/exec_policy.rs:270 |
SandboxManager | 选沙箱类型、把 argv 改写成沙箱启动命令 | sandboxing/src/manager.rs:275 |
execute_env | 统一执行入口:真的 spawn 子进程 | core/src/sandboxing/mod.rs:176 |
apply_patch | 把补丁模糊匹配后落到文件 | apply-patch/src/lib.rs:315 |
guardian | 高风险动作的 LLM 复核护栏 | core/src/guardian/mod.rs |
主线走一遍(高层):
- 回合开始,core 把所有工具的
spec()收集起来,按ToolExposure分级,序列化成 Responses API 的工具 JSON 喂给模型(延迟加载的先不喂,见 §3.2)。 - 模型回一条
tool call。ToolRouter按工具名找到对应的ToolExecutor。 - 如果是命令类,
exec_policy先判定放行/问用户/禁止;编辑类走assess_patch_safety。必要时guardian再做一次 LLM 复核。 - 通过之后,
SandboxManager::transform选平台沙箱,把原始 argv 改写成「沙箱启动器 + 原命令」。 execute_env真的 spawn 子进程,带超时和输出上限,把结果回执给模型。
下面逐个子系统由浅入深钻。
3. 工具体系(tools crate:声明、发现、执行)
这节讲一个工具从「定义」到「被模型看见」到「被调用」的完整链条,以及它和上一章 harness 仿真的接点。
3.1 三个层次的「工具」表示
同一个工具,在代码里有三种形态,分工清楚:
| 形态 | 角色 | 符号 |
|---|---|---|
ToolDefinition | 中间层元数据:名字+描述+输入/输出 schema+是否延迟加载 | tools/src/tool_definition.rs:7 |
ToolSpec | core 内部用的富枚举(Function/Freeform/Namespace/LocalShell/WebSearch…) | tools/src/tool_spec.rs:ToolSpec |
ResponsesApiTool | 最终序列化给模型看的那份 JSON | tools/src/responses_api.rs:26 |
三者之间靠转换函数打通,例如 tool_definition_to_responses_api_tool(responses_api.rs:127)把中间层翻成模型可见 JSON。注意这里有个细节:strict 字段硬编码为 false,且 defer_loading 只 在为真时才序列化出去(.then_some(true)),这样默认工具的 JSON 里根本不出现这个字段。
3.2 延迟加载:先藏起来,用到再给(deferred tools)
它要解决的小问题: 工具太多会撑爆上下文、也让模型分心。很多工具(尤其是 MCP 来的)平时用不上。
思路: 把工具分级——常用的直接喂,冷门的先只登记、不喂给模型,模型需要时用一个专门的 tool_search 工具去搜,搜到了再把完整 schema 补给它。
ToolExposure 就是这个分级开关(tool_executor.rs:15):
| 级别 | 含义 |
|---|---|
Direct | 进初始工具列表;开了 code mode 时也作为嵌套工具可用 |
Deferred | 不进初始列表,只登记待搜;必须提供搜索元数据 |
DirectModelOnly | 只进初始列表,不进 code mode 的嵌套面 |
Hidden | 登记以便分发,但完全不给模型看 |
延迟加载的工具靠 into_deferred()(tool_definition.rs:21)把 output_schema 清空、defer_loading 置真。MCP 工具专门有一条 mcp_tool_to_deferred_responses_api_tool(responses_api.rs:116)走这条路。搜索用的常量与默认条数在 tool_discovery.rs:6(TOOL_SEARCH_TOOL_NAME、TOOL_SEARCH_DEFAULT_LIMIT = 8),搜索元数据结构是 ToolSearchInfo(tool_search.rs:16),默认实现直接从工具的 spec 派生(tool_executor.rs:59 的 search_info)。
这正好呼应本文顶部系统提示里描述的「deferred tools 通过 ToolSearch 载入 schema 才能调用」的机制——OI 把同一套「先给名字、要用再给 schema」的渐进式披露做进了工具层。
3.3 执行契约:ToolExecutor trait
所有模型可见工具共享一个运行时契约(tool_executor.rs:49):
// 真实源码节选,tools/src/tool_executor.rs:49
pub trait ToolExecutor<Invocation>: Send + Sync {
fn tool_name(&self) -> ToolName;
fn spec(&self) -> ToolSpec;
fn exposure(&self) -> ToolExposure { ToolExposure::Direct } // 默认直接暴露
fn handle(&self, invocation: Invocation) -> ToolExecutorFuture<'_>;
}
一句话:把「给模型看的 spec」和「真正执行的 handle」绑在同一个对象上,宿主(core)可以在外面叠路由、hooks、遥测,而不用把 spec 和运行时拆开。handle 返回一个 boxed future,产出 Box<dyn ToolOutput> 或 FunctionCallError。ToolRouter(router.rs:35)在执行期负责 build_tool_call → dispatch,ToolRegistry(registry.rs:322)是名字到运行时的注册表,重复注册会 panic(registry.rs:337)。
3.4 code mode:让模型写 JavaScript 来编排工具
它要解决的小问题: 一个任务要连着调好几个工具时,一次一个 tool call 来回太慢、也容易乱。
思路(这是 OI/Codex 一个挺妙的设计): 给模型一个叫 exec 的工具,模型往里塞一段 JavaScript,所有别的工具都挂在全局 tools 对象上,模型可以 await tools.exec_command(...) 像写脚本一样把多个工具串起来,在一个 V8 isolate 里跑完 再回结果。
这段 exec 工具的描述模板写得很直白(code-mode-protocol/src/description.rs:12 的 EXEC_DESCRIPTION_TEMPLATE),关键几条:
- 在全新 V8 isolate 里把代码当 async module 求值;
- 所有嵌套工具挂在全局
tools,工具名规范化成 JS 标识符(如tools.mcp__ologs__get_profile(...)); - 纯 JavaScript,没有 Node、没有文件系统、没有网络、没有 console——沙箱本身就极窄;
- 还提供
text()/image()/store()/load()/yield_control()等全局辅助函数。
tools crate 侧负责把每个普通工具的 spec 翻译成 code-mode 的嵌套工具定义并在描述里塞一段 TS 声明示例:入口是 augment_tool_spec_for_code_mode(code_mode.rs:8)和 collect_code_mode_tool_definitions(code_mode.rs:61)。工具名到 code-mode 全局名的规范化在 code_mode_name_for_tool_name(code_mode.rs:158),命名空间工具会拼成 namespace__tool。真正渲染 TS 声明样例的是 augment_tool_definition(description.rs:348),它对除 exec/wait 之外的工具都追加一段声明。V8 运行时本体在 code-mode crate(code-mode/src/lib.rs,initialize_v8)。
3.5 MCP 工具:把外部服务的工具翻进来
parse_mcp_tool(mcp_tool.rs:6)把一个 rmcp::model::Tool(MCP 协议里的工具)转成本地 ToolDefinition。这里有个务实的兼容细节:OpenAI 模型要求 schema 里必须有 properties,而有些 MCP server 省了或给了 null,于是这里主动补一个空对象(mcp_tool.rs:12-19),对齐 Agents SDK 的行为。输出统一包成一个 MCP CallToolResult 形状的 schema(mcp_call_tool_result_output_schema,mcp_tool.rs:39)。
3.6 和 harness 编码的接点
工具体系并不是孤立的——它和上一章的 harness 仿真在**「用哪种 shell 工具形态」**这个点上耦合。shell_type_for_model_and_features(tool_config.rs:81)根据模型信息和 feature 开关,在 ShellCommand、UnifiedExec、Disabled 之间选;Harness 枚举(harness.rs:2)列出了 OI 支持仿真的一票 harness(ClaudeCode、KimiCode、QwenCode、OpenCode……)。也就是说:同一个「运行命令」的能力,喂给不同 harness 的模型时,工具的名字/形态/编码可能不同,而工具体系这层负责把这些差异吸收掉。细节见 02-harness-shaping.md。
4. 命令执行(exec / exec_policy / exec_env)
这节讲一条 shell 命令从「决定要不要跑」到「真的跑起来」的两步:先判定(exec_policy),再执行(exec)。
4.1 执行前判定:exec_policy 的三档裁决
它要解决的小问题: 模型想跑的命令,有的绝对安全(ls),有的绝对危险(rm -rf /),大多数在中间。得有个东西给每条命令定性。
exec_policy 的核心产物是 ExecApprovalRequirement,由 create_exec_approval_requirement_for_command(exec_policy.rs:270)算出,落到三档:
裁决(Decision) | 结果 | 位置 |
|---|---|---|
Forbidden | 直接拒,回错误给模型 | exec_policy.rs:328 |
Prompt | 需要审批(可能升级为 Forbidden) | exec_policy.rs:339 |
Allow | 放行(可跳过沙箱审批) | exec_policy.rs:371 |
判定的两个信息源:
- 规则引擎 ——
execpolicycrate 是一套可解析的策略语言(PolicyParser、Policy、Rule,execpolicy/src/lib.rs),规则文件放在rules/目录、扩展名.rules、默认default.rules(exec_policy.rs:50-52)。用户/企业可以自己加规则,blocking_append_allow_prefix_rule支持运行时追加前缀允许规则。 - 启发式 —— 规则没覆盖到的命令,用
is_known_safe_command(已知安全)和dangerous_command_match(危险模式)兜底(exec_policy.rs:28-29)。还有一张BANNED_PREFIX_SUGGESTIONS表(exec_policy.rs:53)列出python3 -c、bash -lc、sudo、osascript等一票解释器/提权前缀,用于把「表面无害、实则能绕过」的命令识别出来。
在 Windows 上,判定还要区分 PowerShell 来源(ExecPolicyCommandOrigin::PowerShell,exec_policy.rs:110),因为 PowerShell 的安全/危险启发式跑在它自己的内层命令词上,而不是通用分类器上。
4.2 执行:process_exec_tool_call 流水
判定通过后,真正执行的门面是 process_exec_tool_call(exec.rs:297):它先调 build_exec_request(exec.rs:321)把「可移植的 exec 请求」变成「具体 argv/env + 沙箱策略」,再统一走 execute_env(core/src/sandboxing/mod.rs:176)spawn。
build_exec_request 里做的事(exec.rs:321-413):
- 从 permission profile 推出文件系统策略+网络策略(
to_runtime_permissions); select_process_exec_tool_sandbox_type选沙箱类型(见 §5.1);- 如果有网络代理,把代理配置注入 env;
- 拆出
program+args,包成SandboxCommand,交给SandboxManager::transform改写(见 §5.2)。
几个硬性护栏常量:
| 常量 | 值 | 作用 |
|---|---|---|
DEFAULT_EXEC_COMMAND_TIMEOUT_MS | 10_000 | 默认命令超时 10 秒 |
EXEC_OUTPUT_MAX_BYTES | = DEFAULT_OUTPUT_BYTES_CAP | 单次 exec 输出保留上限,防一条命令刷爆内存 |
MAX_EXEC_OUTPUT_DELTAS_PER_CALL | 10_000 | 实时输出 delta 事件条数上限 |
IO_DRAIN_TIMEOUT_MS | 2_000 | 杀掉子进程后排水 stdout/stderr 的最长等待 |
超时/取消统一由 ExecExpiration 枚举建模(exec.rs:143),支持纯超时、纯取消、二者取先到(tokio::select! biased,exec.rs:197)。这个 IO_DRAIN_TIMEOUT_MS 有个真实动机:子进程可能 fork 出孙进程继承了管道 fd,杀掉直接子进程后管道还开着,read() 会永久阻塞——所以排水也要有超时,否则整个 agent 挂死(exec.rs:82-89 的注释写得很清楚)。
4.3 环境变量:白名单进,不放毒
exec_env.rs 负责给子进程构造干净的环境。create_env(exec_env.rs:25)按 ShellEnvironmentPolicy 过滤,调用方要先 env_clear() 再 envs(),确保不泄漏宿主的杂七杂八变量。
一个安全细节:inject_permission_profile_env(exec_env.rs:37)会把当前生效的权限档名写进 CODEX_PERMISSION_PROFILE 环境变量,但注释明说这只是信息性的、子进程能覆写、绝不能当作强制证明(exec_env.rs:11-13)。真正的强制靠沙箱,不靠环境变量。
5. 原生沙箱(sandboxing crate:三平台各一套)
这节是本章的护栏核心:命令即使被放行,也关在一个尽量小的笼子里跑。README 的「native sandboxing」就在这。
5.1 先决定:要不要沙箱、用哪种
SandboxManager(manager.rs:275)分两步走:
第一步 should_sandbox / select_initial(manager.rs:280、303): 按偏好 SandboxablePreference(Auto/Require/Forbid)决定要不要沙箱。Auto 时委托给 should_require_platform_sandbox(policy_transforms.rs:509),逻辑是:
- 有托管网络要求 → 一定要沙箱;
- 网络关着 → 除非是「外部沙箱」类型,否则要沙箱;
- 网络开着 → 只有「受限文件系统且不能全盘写」才要沙箱。
第二步选平台: get_platform_sandbox(manager.rs:60)按编译目标平台挑一种:
| 平台 | SandboxType | 底层机制 |
|---|---|---|
| macOS | MacosSeatbelt | sandbox-exec + SBPL 策略 |
| Linux | LinuxSeccomp | codex-linux-sandbox 助手(bubblewrap + seccomp) |
| Windows | WindowsRestrictedToken | 受限令牌 / 提权后端(仅在开关打开时) |
| 其它/关闭 | None | 不沙箱 |
5.2 再改写:transform 把 argv 包进沙箱
SandboxManager::transform(manager.rs:321)是关键:它按选中的 SandboxType,把原始 argv 改写成「沙箱启动器 + 原命令」。三平台分支各不相同(manager.rs:357-426):
- macOS: 前面拼
/usr/bin/sandbox-exec+ 生成的-p <策略>参数(见 §5.3); - Linux: 前面拼
codex-linux-sandbox可执行文件路径,并设置 arg0 覆盖(见 §5.4); - Windows: 直接留用,后续在 direct-spawn 阶段再包一层受限令牌 wrapper(见 §5.5)。
一个防篡改细节:macOS 只认 /usr/bin/sandbox-exec 这个绝对路径(MACOS_PATH_TO_SEATBELT_EXECUTABLE,seatbelt.rs:30),绝不查 PATH——注释说得直白:如果 /usr/bin/sandbox-exec 都被改了,那攻击者早就有 root 了(seatbelt.rs:26-29)。
5.3 macOS:Seatbelt 策略是「拼出来」的
macOS 的策略不是一个静态文件,而是运行时按权限拼接的字符串。create_seatbelt_command_args(seatbelt.rs:623)把若干段策略 join 起来(seatbelt.rs:741):
policy_sections = [
基础策略(seatbelt_base_policy.sbpl,静态,deny default)
文件读策略(按可读根动态生成)
文件写策略(按可写根动态生成)
拒读策略(unreadable 通配)
网络策略(按代理/端口/unix socket 动态生成)
(可选)受限只读平台默认
]
基础策略 seatbelt_base_policy.sbpl 的第一句就是 (deny default)(seatbelt_base_policy.sbpl:8)——默认全关,再逐条开。它开的都是极窄的口子:子进程 exec/fork、读一批 hw.*/kern.* sysctl、openpty、读只读用户偏好等,连 /dev/null 的写都要 require-all 限定到字符设备。
文件读写用 build_seatbelt_access_policy(seatbelt.rs:352)按「可写根/可读根」生成带 -D 参数占位的规则;全盘写会退化成 (allow file-write* (regex #"^/"))(seatbelt.rs:645,注释称比 (allow file-write*) 更宽松)。网络策略最有意思:dynamic_network_policy_for_network(seatbelt.rs:274)在「有代理配置但推不出合法回环端点」时fail closed 直接返回空策略(seatbelt.rs:312-316),宁可断网也不静默放宽。unix socket 支持 AllowAll 和按 subpath 限定两种(seatbelt.rs:225)。最终这一整段策略作为 -p 的实参传给 sandbox-exec(seatbelt.rs:761)。
5.4 Linux:自己 fork 一个沙箱助手
Linux 走的是自调用助手模式:core 不在本进程里施加隔离,而是把命令交给一个专门的 codex-linux-sandbox 可执行文件去跑。create_linux_sandbox_command_args_for_permission_profile(landlock.rs:23)拼出助手的 CLI:把权限档序列化成 JSON 传进去,加上 --sandbox-policy-cwd、--command-cwd,末尾 -- 后接原命令。
助手本体在 linux-sandbox crate,它的 lib 头注释讲清了它施加两层(linux-sandbox/src/lib.rs:1-5):
- 进程内限制:
no_new_privs+ seccomp(限制系统调用); - bubblewrap: 文件系统隔离。
助手通过 arg0 识别自己(CODEX_LINUX_SANDBOX_ARG0 = "codex-linux-sandbox",landlock.rs:6),run_main(linux-sandbox/src/lib.rs:24)是入口。有个环境探测:bubblewrap 需要能创建 user namespace,sandboxing/src/bwrap.rs 会提前探测系统 bwrap 是否可用、是否在 WSL1(is_wsl1,bwrap.rs:138;WSL1 建不了 namespace,直接给警告 WSL1_BWRAP_WARNING,bwrap.rs:25)。旧机制 landlock 仍保留(--use-legacy-landlock),但代理网络场景强制走 bubblewrap 的隔离网络命名空间(landlock.rs:50-56)。
5.5 Windows:受限令牌,两种后端
Windows 用**受限访问令牌(restricted token)**这一 Win32 原生机制降权。windows_sandbox_uses_elevated_backend(windows.rs:32)决定用哪种后端:代理强制或配置为 Elevated 时用提权后端,否则用默认的受限令牌后端。文件系统允许/拒绝清单通过 resolve_windows_restricted_token_filesystem_overrides(windows.rs:78)/resolve_windows_elevated_filesystem_overrides(windows.rs:214)解析成 WindowsSandboxFilesystemOverrides(windows.rs:24)。受限令牌沙箱要求权限档不能全盘写(permission_profile_supports_windows_restricted_token_sandbox,windows.rs:42)。Windows 的包裹发生在 direct-spawn 阶段:transform_for_direct_spawn(manager.rs:464)会再套一层 wrapper 命令并把 SandboxType 重置为 None(因为隔离已经编码进 wrapper 里了,manager.rs:591)。
5.6 三平台差异速查
| 维度 | macOS | Linux | Windows |
|---|---|---|---|
| 底层机制 | sandbox-exec + SBPL | bubblewrap + seccomp | 受限访问令牌 |
| 隔离在哪施加 | 拼 -p 策略,同进程树 | fork 独立助手进程 | direct-spawn wrapper |
| 策略形态 | 运行时拼的 SBPL 字符串 | 命令行参数(JSON 权限档) | 过滤清单 overrides |
| 默认姿态 | (deny default) 全关再开 | seccomp allowlist + bwrap 绑定 | 令牌降权 + 读写根清单 |
| 网络 | 动态 SBPL,推不出端点则断网 | 代理走隔离网络命名空间 | 防火墙绑登录用户身份 |
| 关键坑 | 只认 /usr/bin/sandbox-exec | WSL1 建不了 namespace | 全盘写档不支持受限令牌 |
6. 文件编辑(apply_patch + apply-patch crate)
这节讲 agent 改代码那一支,和命令执行并列。核心难点前面点过:模型给的「旧代码」几乎从不和磁盘一字不差。
6.1 补丁怎么落地
上层入口 core/src/apply_patch.rs 的 apply_patch(apply_patch.rs:34)先做安全评估 assess_patch_safety,产出三档 SafetyCheck(AutoApprove / AskUser / Reject),对应到 InternalApplyPatchInvocation(apply_patch.rs:14)——要么当场返回、要么委托运行时通过所选环境的文件系统真正落盘。补丁的解析和应用在 apply-patch crate,apply_hunks(apply-patch/src/lib.rs:315)是应用入口,compute_replacements(lib.rs:716)算出每处替换,apply_replacements 从后往前打(lib.rs:806 附近,倒序是为了不让前面的替换打乱后面的下标)。
6.2 巧妙之处:seek_sequence 的四级模糊匹配
这是本章最值得借鉴的一处。 问题:补丁里的上下文行(old_lines)要在文件里定位,但空格、制表符、花引号、破折号都可能对不上。硬匹配会大量失败。
seek_sequence(apply-patch/src/seek_sequence.rs:12)的解法是逐级放宽,命中即停:
┌─────────────────────────────────────────────┐
pattern ─► │ ① 精确匹配 (整行逐字节相等) │─命中─► 返回下标
│ │ 失败 │
│ ▼ │
│ ② 忽略行尾空白 (trim_end 后比) │─命中─►
│ │ 失败 │
│ ▼ │
│ ③ 忽略首尾空白 (trim 后比) │─命中─►
│ │ 失败 │
│ ▼ │
│ ④ Unicode 归一 (花引号/破折号/异体空格→ASCII)│─命中─►
│ │ 全失败 │
│ ▼ None │
└─────────────────────────────────────────────┘
四级依次是:精确 → trim_end 忽略行尾空白(seek_sequence.rs:41)→ trim 忽略首尾空白(seek_sequence.rs:54)→ 最宽松的一遍:把各种 Unicode 破折号(\u{2010}–\u{2015}、\u{2212})、花引号(\u{2018} 等)、异体空格(\u{00A0}、全角空格 \u{3000} 等)归一成 ASCII 再比(normalise,seek_sequence.rs:76)。注释直言这是模仿 git apply 的模糊行为(seek_sequence.rs:67-74)。
还有两个防御性边界:空 pattern 直接返回 Some(start)(no-op);pattern.len() > lines.len() 直接返回 None(seek_sequence.rs:26),注释说这修的是 2025-04-12 之前会越界 panic 的 bug。eof 为真时会先从文件末尾对齐再回退(seek_sequence.rs:29),让「改文件结尾」的补丁能可靠命中。
compute_replacements 用它的方式也很务实:当直接匹配失败、且 pattern 最后一行是空串(代表文件末尾换行的哨兵)时,会去掉那个尾部空行再试一次(lib.rs:771-785),因为 split('\n') 会造出一个真实文件里不存在的尾部空片段。