Skip to main content

nichlink_plugin_host/
process.rs

1//! Process-isolated plugin execution with a hard per-call deadline.
2//! 带单次调用硬超时的进程隔离插件执行。
3
4use std::{
5    fs,
6    io::Write,
7    path::{Path, PathBuf},
8    sync::mpsc::{self, TryRecvError},
9    thread,
10    time::{Duration, Instant},
11};
12
13use nichlink_run_method::{PluginAdapter, VerifiedPluginArtifact};
14use tempfile::{Builder, TempPath};
15
16use crate::{HostError, PluginInstance};
17
18#[path = "process/child.rs"]
19mod child;
20
21use child::{POLL_INTERVAL, drain_to_eof, kill_and_reap, read_frame, read_stderr, spawn_staged};
22
23/// Limits for one isolated process call.
24/// 单次隔离进程调用的限制。
25#[derive(Clone, Copy, Debug)]
26pub struct ProcessLimits {
27    /// Wall-clock deadline for one call; on expiry the host kills the child.
28    /// 单次调用的挂钟超时;到期即由宿主终止子进程。
29    pub timeout: Duration,
30    /// Largest request payload accepted, in bytes.
31    /// 接受的最大请求负载字节数。
32    pub max_input_bytes: usize,
33    /// Largest response payload accepted, in bytes. Checked against the length
34    /// the child declares, before the host allocates the buffer.
35    /// 接受的最大响应负载字节数。以子进程声明的长度为准,在宿主分配缓冲区之前检查。
36    pub max_output_bytes: usize,
37    /// Whether the child inherits this host's environment.
38    /// 子进程是否继承本宿主的环境。
39    ///
40    /// The default is `true`, which is what a host that has always run its plugin
41    /// this way expects. Setting it to `false` clears the environment instead, so
42    /// an untrusted plugin cannot read the host's tokens, credentials, or
43    /// configuration out of it; a host that needs to pass something specific adds
44    /// it with [`ProcessProgram::environment`], and the plugin executable itself
45    /// still starts because it is run by absolute path.
46    /// 默认是 `true`,也就是一直这样运行插件的宿主所期望的行为。设为 `false` 时会清空环境,
47    /// 使不受信任的插件无法从中读到宿主的令牌、凭据或配置;需要传特定变量的宿主用
48    /// [`ProcessProgram::environment`] 显式添加,而插件可执行文件本身仍能启动,因为它以绝对
49    /// 路径执行。
50    pub inherit_env: bool,
51}
52
53impl Default for ProcessLimits {
54    fn default() -> Self {
55        Self {
56            timeout: Duration::from_secs(2),
57            max_input_bytes: 1024 * 1024,
58            max_output_bytes: 1024 * 1024,
59            inherit_env: true,
60        }
61    }
62}
63
64/// Executable, fixed arguments, and child environment for an isolated plugin.
65/// 隔离插件使用的程序、固定参数与子进程环境。
66#[derive(Clone, Debug)]
67pub struct ProcessProgram {
68    executable: PathBuf,
69    arguments: Vec<String>,
70    environment: Vec<(String, String)>,
71    current_dir: Option<PathBuf>,
72}
73
74impl ProcessProgram {
75    /// Start a program specification with no fixed arguments.
76    /// 以无固定参数开始描述一个程序。
77    pub fn new(executable: impl Into<PathBuf>) -> Self {
78        Self {
79            executable: executable.into(),
80            arguments: Vec::new(),
81            environment: Vec::new(),
82            current_dir: None,
83        }
84    }
85
86    /// Append one fixed argument, passed before every operation name.
87    /// 追加一个固定参数,它排在每个操作名之前。
88    pub fn argument(mut self, argument: impl Into<String>) -> Self {
89        self.arguments.push(argument.into());
90        self
91    }
92
93    /// Set one environment variable for the child.
94    /// 为子进程设置一个环境变量。
95    ///
96    /// This is the companion of [`ProcessLimits::inherit_env`]: with the
97    /// environment cleared, these are the only variables the child sees, so a
98    /// host passes what the plugin genuinely needs instead of handing over
99    /// everything it has.
100    /// 这是 [`ProcessLimits::inherit_env`] 的配套:清空环境后,子进程只看到这里设置的变量,
101    /// 因此宿主传的是插件真正需要的东西,而不是把自己拥有的一切都交出去。
102    pub fn environment(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
103        self.environment.push((key.into(), value.into()));
104        self
105    }
106
107    /// Run the child in `directory` instead of the host's working directory.
108    /// 让子进程在 `directory` 中运行,而不是宿主的工作目录。
109    pub fn current_dir(mut self, directory: impl Into<PathBuf>) -> Self {
110        self.current_dir = Some(directory.into());
111        self
112    }
113
114    /// The configured executable path, before staging.
115    /// 配置的可执行文件路径,尚未暂存。
116    pub fn executable(&self) -> &Path {
117        &self.executable
118    }
119}
120
121/// Process-isolated plugin loader.
122/// 进程隔离插件加载器。
123#[derive(Clone, Copy, Debug, Default)]
124pub struct ProcessBackend {
125    limits: ProcessLimits,
126}
127
128impl ProcessBackend {
129    /// Build a backend that applies `limits` to every loaded instance.
130    /// 构造一个对每个已加载实例施加 `limits` 的后端。
131    pub const fn new(limits: ProcessLimits) -> Self {
132        Self { limits }
133    }
134
135    /// Stage the binary privately and require it to equal the artifact bytes.
136    /// The returned instance runs that staged copy, so later edits to the original path
137    /// cannot change what executes.
138    /// 把二进制暂存到私有位置并要求它与工件字节相等。返回的实例运行该暂存副本,
139    /// 因此之后改动原路径不会改变实际执行的内容。
140    pub fn load(
141        &self,
142        artifact: VerifiedPluginArtifact,
143        program: ProcessProgram,
144    ) -> Result<ProcessInstance, HostError> {
145        let metadata = std::fs::metadata(program.executable())?;
146        if !metadata.is_file() {
147            return Err(HostError::InvalidArtifact(format!(
148                "{} is not a file",
149                program.executable().display()
150            )));
151        }
152        let (_, verified_bytes) = artifact.into_parts();
153        // The length the filesystem reports decides before anything is read: the
154        // comparison below used to be the first use of the size, so a file whose
155        // digest could not possibly match — a 2 GiB sparse file, say — was read
156        // into memory in full before the mismatch could refuse it. Once the
157        // lengths agree, reading it costs exactly what the verified bytes
158        // already cost.
159        // 由文件系统报告的长度在读取之前先做判断:下面那次比较过去是第一次用到尺寸,因此一个
160        // 摘要不可能匹配的文件——比如 2 GiB 的稀疏文件——会被整份读进内存后才被拒绝。长度一致
161        // 之后,读取的代价与已持有的已验证字节相同。
162        if metadata.len() != verified_bytes.len() as u64 {
163            return Err(HostError::InvalidArtifact(
164                "process executable differs from the verified bytes".to_owned(),
165            ));
166        }
167        if fs::read(program.executable())? != verified_bytes {
168            return Err(HostError::InvalidArtifact(
169                "process executable differs from the verified bytes".to_owned(),
170            ));
171        }
172        let (program, staged_artifact) = stage_program(program, &verified_bytes)?;
173        Ok(ProcessInstance {
174            program,
175            _staged_artifact: staged_artifact,
176            limits: self.limits,
177        })
178    }
179}
180
181/// A process plugin. Each call gets a fresh child and a hard deadline.
182/// 进程插件。每次调用使用独立子进程和硬超时。
183pub struct ProcessInstance {
184    program: ProcessProgram,
185    _staged_artifact: TempPath,
186    limits: ProcessLimits,
187}
188
189fn stage_program(
190    program: ProcessProgram,
191    bytes: &[u8],
192) -> Result<(ProcessProgram, TempPath), HostError> {
193    let suffix = program
194        .executable
195        .extension()
196        .and_then(|extension| extension.to_str())
197        .map_or_else(String::new, |extension| format!(".{extension}"));
198    let mut staged = Builder::new()
199        .prefix("nichlink-plugin-")
200        .suffix(&suffix)
201        .tempfile()?;
202    staged.write_all(bytes)?;
203    staged.flush()?;
204    set_executable(staged.path())?;
205    let path = staged.into_temp_path();
206    Ok((
207        ProcessProgram {
208            executable: path.to_path_buf(),
209            arguments: program.arguments,
210            // Staging moves the executable, not how the child is run: the
211            // environment and working directory the host chose travel with it.
212            // 暂存搬的是可执行文件,而不是子进程的运行方式:宿主选定的环境与工作目录随它一起走。
213            environment: program.environment,
214            current_dir: program.current_dir,
215        },
216        path,
217    ))
218}
219
220#[cfg(unix)]
221fn set_executable(path: &Path) -> Result<(), HostError> {
222    use std::os::unix::fs::PermissionsExt;
223
224    fs::set_permissions(path, fs::Permissions::from_mode(0o500))?;
225    Ok(())
226}
227
228#[cfg(not(unix))]
229fn set_executable(_: &Path) -> Result<(), HostError> {
230    Ok(())
231}
232
233impl PluginInstance for ProcessInstance {
234    fn adapter(&self) -> PluginAdapter {
235        PluginAdapter::Process
236    }
237
238    /// Run one operation in a fresh child process.
239    /// 在全新的子进程中执行一次操作。
240    fn call(&self, operation: &str, input: &[u8]) -> Result<Vec<u8>, HostError> {
241        // Why the direct implementation is wrong, and where the boundary is:
242        // 直白实现为什么是错的,以及边界在哪里:
243        //
244        // The direct shape is "write stdin, poll `try_wait`, then read stdout".
245        // That shape has two unbounded blocks. Both were measured against this
246        // crate before this comment was written, and both are pinned by tests in
247        // `plugin-host/tests/fault_matrix.rs`:
248        // 直白写法是"写 stdin、轮询 `try_wait`、再读 stdout"。它有两处无界阻塞。两处都
249        // 在写下这段注释之前对本 crate 实测过,并都由 `plugin-host/tests/fault_matrix.rs`
250        // 的测试钉住:
251        //
252        // 1. A pipe holds only about 64 KiB. A poll loop that never drains stdout
253        //    lets the child block inside `write`, so it never exits, so the host
254        //    kills a healthy child and reports `Timeout`. Measured: a 65_536-byte
255        //    frame succeeded and a 65_537-byte frame timed out, while
256        //    `max_output_bytes` claimed 1 MiB. The real ceiling was the pipe
257        //    buffer, and the reported failure had the wrong kind.
258        // 1. 管道只有约 64 KiB。不排空 stdout 的轮询循环会让子进程阻塞在 `write` 里,
259        //    永不退出,于是宿主杀掉一个健康的子进程并报 `Timeout`。实测:65_536 字节的
260        //    帧成功、65_537 字节的帧超时,而 `max_output_bytes` 声称 1 MiB。真实上限是
261        //    管道缓冲,且报告出来的失败种类是错的。
262        // 2. Writing stdin happened before the deadline was armed, so a child that
263        //    does not read stdin pinned the caller with no timeout at all.
264        //    Measured: a 1 MiB input (exactly `max_input_bytes`) to a child that
265        //    never reads blocked for that child's whole lifetime.
266        // 2. 写 stdin 发生在超时启动之前,因此不读 stdin 的子进程会把调用方无限期钉住。
267        //    实测:1 MiB 输入(正好等于 `max_input_bytes`)写给一个从不读 stdin 的子
268        //    进程,阻塞了整个子进程生存期。
269        //
270        // Both are pipe-capacity problems, not timeout problems, so the fix is to
271        // stop using the pipes as synchronization points: input is written from
272        // its own thread, stdout and stderr are drained from their own threads,
273        // and the deadline bounds the whole call. The declared limits become the
274        // real limits.
275        // 两者都是管道容量问题而不是超时问题,因此修法是让管道不再承担同步职责:输入在
276        // 自己的线程里写,stdout 与 stderr 各自有线程排空,超时覆盖整个调用。声明的限制
277        // 由此成为真实的限制。
278        //
279        // Residual boundary, stated rather than hidden: `Child::kill` kills only
280        // the direct child. A plugin that forks a grandchild inheriting the pipes
281        // can keep one open; the call still returns at the deadline, but that
282        // call's detached writer or reader thread can stay blocked until the
283        // grandchild exits. Killing the whole process group would need `libc`,
284        // which this crate does not depend on.
285        // 仍然存在的边界,明说而不隐藏:`Child::kill` 只杀直接子进程。插件若派生继承了
286        // 管道的孙进程,该孙进程可以让管道保持打开;调用仍会在超时点返回,但这次调用的
287        // 写入或读取线程可能一直阻塞到孙进程退出。要连进程组一起杀就需要 `libc`,而本
288        // crate 没有该依赖。
289        validate_operation(operation)?;
290        if input.len() > self.limits.max_input_bytes || input.len() > u32::MAX as usize {
291            return Err(HostError::Limit(format!(
292                "input is {} bytes; limit is {}",
293                input.len(),
294                self.limits.max_input_bytes
295            )));
296        }
297        let mut child = spawn_staged(&self.program, self.limits, operation)?;
298
299        // One frame, built once: the 4-byte little-endian length prefix followed
300        // by the payload. Moving it into the writer thread is also what lets the
301        // caller's slice end with this call.
302        // 一次构造完整帧:4 字节小端长度前缀加负载。把它移动进写入线程,也正是调用方的
303        // 切片可以随这次调用结束而失效的原因。
304        let mut request = Vec::with_capacity(4 + input.len());
305        request.extend_from_slice(&(input.len() as u32).to_le_bytes());
306        request.extend_from_slice(input);
307
308        let mut stdin = child
309            .stdin
310            .take()
311            .ok_or_else(|| HostError::Process("child stdin was unavailable".to_owned()))?;
312        // The writer runs on its own thread so a child that never reads stdin
313        // cannot pin the caller. Once the child dies the pending write fails with
314        // a broken pipe and this thread ends.
315        // 写入放在自己的线程上,不读 stdin 的子进程因此无法钉住调用方。子进程死后挂起的
316        // 写入会以 broken pipe 失败,该线程随之结束。
317        thread::spawn(move || {
318            let _ = stdin.write_all(&request);
319            // Closing stdin is what tells a reading child the request ended.
320            // 关闭 stdin 是告诉正在读取的子进程"请求结束"的方式。
321            drop(stdin);
322        });
323
324        let mut stdout = child
325            .stdout
326            .take()
327            .ok_or_else(|| HostError::Process("child stdout was unavailable".to_owned()))?;
328        let max_output = self.limits.max_output_bytes;
329        let (frames, received) = mpsc::channel();
330        // Draining stdout for the child's whole life is what removes the pipe
331        // buffer from the contract. `read_frame` rejects an over-limit declared
332        // length before it allocates, so the cap also bounds memory.
333        // 在整个子进程生存期内排空 stdout,正是把管道缓冲从契约里移除的那一步。
334        // `read_frame` 在分配之前就拒绝超过上限的声明长度,因此该上限同时约束内存。
335        thread::spawn(move || {
336            // The frame leaves before the rest of stdout is drained, so a child
337            // that answers and then keeps talking still delivers its answer at
338            // once; draining is what keeps that child from blocking on a full
339            // pipe while the host waits for it to exit. Reading one frame and
340            // stopping was the bug: the child blocked, the host killed it at the
341            // deadline, and a delivered answer was reported as a timeout.
342            // 帧在排空 stdout 其余部分之前送出,因此先作答、后继续说话的子进程仍会立刻交付
343            // 答案;排空正是让那个子进程不会在宿主等它退出时阻塞在满管道上的东西。只读一帧就
344            // 停下曾是缺陷:子进程阻塞、宿主在超时点杀掉它,而一个已经送达的答案被报成超时。
345            let frame = read_frame(&mut stdout, max_output);
346            let _ = frames.send(frame);
347            drain_to_eof(&mut stdout);
348        });
349
350        let mut stderr = child
351            .stderr
352            .take()
353            .ok_or_else(|| HostError::Process("child stderr was unavailable".to_owned()))?;
354        let (messages, message_text) = mpsc::channel();
355        // stderr is drained for the same reason, and the thread keeps reading to
356        // EOF after the retained prefix fills so that no child can block on it.
357        // stderr 因同样原因被排空;保留的前缀写满之后线程仍继续读到 EOF,因此没有子进程
358        // 会因它阻塞。
359        thread::spawn(move || {
360            let _ = messages.send(read_stderr(&mut stderr));
361        });
362
363        let deadline = Instant::now() + self.limits.timeout;
364        let mut frame: Option<Vec<u8>> = None;
365        let mut refusal: Option<HostError> = None;
366        let status = loop {
367            if frame.is_none() && refusal.is_none() {
368                match received.try_recv() {
369                    Ok(Ok(bytes)) => frame = Some(bytes),
370                    // An over-limit declared length is final: the reader has
371                    // stopped reading, so waiting for a child that may now be
372                    // blocked on a full pipe would degrade `Limit` into a
373                    // misleading `Timeout`.
374                    // 超过上限的声明长度是最终结论:读取线程已经停止读取,此时去等一个可能
375                    // 正阻塞在满管道上的子进程,只会把 `Limit` 降级成误导性的 `Timeout`。
376                    Ok(Err(error @ HostError::Limit(_))) => {
377                        kill_and_reap(&mut child);
378                        return Err(error);
379                    }
380                    // Any other reader failure is only remembered, because the
381                    // usual cause is a child that wrote no frame at all: it is
382                    // about to be reported as `Process` with its stderr, and a
383                    // broken frame would be the wrong diagnosis.
384                    // 其他读取失败只被记下,因为常见原因是子进程根本没写帧:它马上会被以
385                    // `Process` 连同 stderr 上报,而"坏帧"会是错误的诊断。
386                    Ok(Err(error)) => refusal = Some(error),
387                    Err(TryRecvError::Empty) => {}
388                    Err(TryRecvError::Disconnected) => {
389                        refusal = Some(HostError::Process(
390                            "the child stdout reader stopped".to_owned(),
391                        ));
392                    }
393                }
394            }
395            if let Some(status) = child.try_wait()? {
396                break status;
397            }
398            if Instant::now() >= deadline {
399                kill_and_reap(&mut child);
400                return Err(HostError::Timeout);
401            }
402            thread::sleep(POLL_INTERVAL);
403        };
404        if !status.success() {
405            let detail = message_text
406                .recv_timeout(deadline.saturating_duration_since(Instant::now()))
407                .unwrap_or_default();
408            let detail = detail.trim();
409            return Err(HostError::Process(if detail.is_empty() {
410                format!("child exited with {status}")
411            } else {
412                detail.to_owned()
413            }));
414        }
415        if let Some(bytes) = frame {
416            return Ok(bytes);
417        }
418        if let Some(error) = refusal {
419            return Err(error);
420        }
421        // The child is gone, so the reader is at EOF and returns without further
422        // blocking. The wait can expire only when a grandchild still holds the
423        // write end open, and then the deadline is the honest answer.
424        // 子进程已消失,读取线程已到 EOF,不会再阻塞。只有当孙进程仍持有写端时这个等待
425        // 才会到期,此时返回超时才是诚实的答案。
426        received
427            .recv_timeout(deadline.saturating_duration_since(Instant::now()))
428            .map_err(|_| HostError::Timeout)?
429    }
430}
431
432fn validate_operation(operation: &str) -> Result<(), HostError> {
433    if nichlink_run_method::validate_operation_name(operation).is_err() {
434        return Err(HostError::InvalidOperation(operation.to_owned()));
435    }
436    Ok(())
437}