ic_host_process/tool/mod.rs
1//! Executable admission and bounded execution of caller-selected Unix commands.
2//!
3//! Consumers own pins, arguments, credentials, environment and trusted executable
4//! directories. Execution is not a sandbox or process-tree supervisor. Calls
5//! execute once, including on timeout or ambiguous completion; no retry occurs.
6
7mod diagnostics;
8mod process;
9mod resolution;
10#[cfg(test)]
11mod tests;
12
13pub use diagnostics::format_failure_evidence;
14pub use resolution::{ResolutionError, resolve_executable};
15
16use ic_host_artifacts::artifact::{ArtifactError, ArtifactIdentity, Sha256Digest};
17use ic_host_fs::read::{hash_file_no_follow_with_metadata, hash_file_with_metadata};
18use std::{
19 ffi::OsString,
20 fmt, fs, io,
21 os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _},
22 path::{Path, PathBuf},
23 process::{Command, ExitStatus},
24 time::{Duration, Instant},
25};
26
27/// Retention and overflow behavior for one output stream.
28#[derive(Clone, Copy, Debug, Eq, PartialEq)]
29pub enum OutputLimit {
30 /// Retain at most this many bytes; excess output fails and cleans up the child.
31 Terminate(usize),
32 /// Retain this prefix, drain the rest, and report every byte to an observer.
33 /// Truncation is recorded in evidence, without failing the command.
34 Truncate(usize),
35}
36
37impl OutputLimit {
38 /// Maximum retained bytes, independently of overflow behavior.
39 #[must_use]
40 pub const fn retained_bytes(self) -> usize {
41 match self {
42 Self::Terminate(bytes) | Self::Truncate(bytes) => bytes,
43 }
44 }
45}
46
47/// Caller-selected output retention and optional elapsed-time deadline.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub struct OutputLimits {
50 /// Standard output retention and overflow behavior.
51 pub stdout: OutputLimit,
52 /// Standard error retention and overflow behavior.
53 pub stderr: OutputLimit,
54 /// Positive deadline from immediately before spawning through output EOF,
55 /// or from entry to [`communicate_child`] for an already spawned child.
56 /// Verification reads, spawning syscalls and kill/reap may take longer.
57 ///
58 /// `None` still enforces output bounds, polls cancellation and cleans up on
59 /// failure. It can wait indefinitely for child exit or pipe EOF, including
60 /// descendant-held pipes with [`SuccessfulExit::Retain`]. Choose cancellation
61 /// and successful-exit policy deliberately. Cleanup timing remains governed
62 /// by the child's [`crate::child::CleanupPolicy`].
63 pub timeout: Option<Duration>,
64}
65
66/// Explicit process context. The child's inherited environment is cleared.
67///
68/// No ambient PATH, HOME or credentials are added. Consumers must include any
69/// environment needed by their tool. This type intentionally has no `Debug`
70/// implementation because its values may contain credentials.
71pub struct ExecutionContext<'a> {
72 /// Absolute current directory for the child.
73 pub current_dir: &'a Path,
74 /// Complete environment; names must be nonempty, unique, and contain no
75 /// `=` or NUL bytes. Values must contain no NUL bytes.
76 pub environment: &'a [(OsString, OsString)],
77}
78
79/// Executable and version-probe configuration selected by a consumer.
80///
81/// Verifying operations require a separate exact version authority. Pinned
82/// admission additionally requires a trusted digest. Observation executes a
83/// caller-trusted installation without an independently selected version.
84/// Consumers own installation provenance; a version does not authenticate bytes.
85pub struct VersionSpec<'a> {
86 /// Absolute path in a caller-controlled filesystem; no PATH search occurs.
87 pub executable: &'a Path,
88 /// Maximum executable bytes read during admission and every later run.
89 pub executable_bytes: u64,
90 /// Exact argument vector used once to observe version identity.
91 pub version_arguments: &'a [OsString],
92}
93
94/// Invocation parameters rejected before dispatch or communication.
95/// An already spawned child remains owned and untouched by this validation.
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum InvalidInvocation {
98 /// Tool paths must be absolute.
99 ExecutablePath,
100 /// Working directories must be absolute.
101 WorkingDirectory,
102 /// Deadlines must be positive and representable by the host clock.
103 Deadline,
104 /// Version identity must be nonempty and already trimmed.
105 VersionIdentity,
106 /// An argument contains a NUL byte.
107 Argument {
108 /// Argument position.
109 index: usize,
110 },
111 /// An environment name is empty, contains `=`/NUL, or duplicates an earlier name.
112 EnvironmentName {
113 /// Environment entry position.
114 index: usize,
115 },
116 /// An environment value contains a NUL byte.
117 EnvironmentValue {
118 /// Environment entry position.
119 index: usize,
120 },
121}
122
123/// A captured output stream.
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
125pub enum OutputStream {
126 /// Standard output.
127 Stdout,
128 /// Standard error.
129 Stderr,
130}
131
132/// Bounded evidence retained after an invocation or admission failure.
133///
134/// Bytes are available explicitly to the caller; formatting prints only lengths
135/// and status. Error messages never include command arguments or environment.
136#[derive(Default)]
137pub struct ExecutionEvidence {
138 /// Observed direct-child status. Communication can reserve a successful
139 /// leader without reaping it. Status does not prove external effects absent.
140 pub status: Option<ExitStatus>,
141 /// Retained stdout prefix, bounded by the selected limit.
142 pub stdout: Vec<u8>,
143 /// Retained stderr prefix, bounded by the selected limit.
144 pub stderr: Vec<u8>,
145 /// More stdout bytes were observed than could be retained.
146 pub stdout_truncated: bool,
147 /// More stderr bytes were observed than could be retained.
148 pub stderr_truncated: bool,
149}
150
151/// One completed invocation, with a required exit status and bounded output.
152///
153/// Completion includes nonzero exits and signals. It does not imply successful
154/// application effects. Use [`Self::require_success`] before success-only parsing.
155/// A retained successful child still requires caller-owned disposition.
156pub struct CompletedExecution {
157 /// Direct-child exit status, including nonzero exits and signals.
158 pub status: ExitStatus,
159 /// Retained stdout prefix.
160 pub stdout: Vec<u8>,
161 /// Retained stderr prefix.
162 pub stderr: Vec<u8>,
163 /// More stdout bytes were observed than retained.
164 pub stdout_truncated: bool,
165 /// More stderr bytes were observed than retained.
166 pub stderr_truncated: bool,
167}
168
169impl CompletedExecution {
170 /// Require a successful exit while preserving output on rejection.
171 ///
172 /// # Errors
173 /// Returns [`ExecutionFailure::ExitStatus`] for a nonzero or signalled exit.
174 /// This does not perform child cleanup or transfer retained ownership.
175 pub fn require_success(self) -> Result<Self, ToolError> {
176 if self.status.success() {
177 Ok(self)
178 } else {
179 Err(self.failure(ExecutionFailure::ExitStatus))
180 }
181 }
182
183 /// Require complete output before parsing or treating it as an identity.
184 ///
185 /// # Errors
186 /// Returns an output-limit failure with the original evidence if either
187 /// stream was truncated. This does not perform child cleanup; callers using
188 /// retained ownership still own the child's disposition.
189 pub fn require_complete(self) -> Result<Self, ToolError> {
190 let stream = if self.stdout_truncated {
191 OutputStream::Stdout
192 } else if self.stderr_truncated {
193 OutputStream::Stderr
194 } else {
195 return Ok(self);
196 };
197 Err(self.failure(ExecutionFailure::OutputLimit { stream }))
198 }
199
200 fn failure(self, failure: ExecutionFailure) -> ToolError {
201 ToolError::Execution(Box::new(ExecutionError {
202 failure,
203 evidence: self.into(),
204 cleanup: None,
205 }))
206 }
207}
208
209impl From<CompletedExecution> for ExecutionEvidence {
210 fn from(completed: CompletedExecution) -> Self {
211 Self {
212 status: Some(completed.status),
213 stdout: completed.stdout,
214 stderr: completed.stderr,
215 stdout_truncated: completed.stdout_truncated,
216 stderr_truncated: completed.stderr_truncated,
217 }
218 }
219}
220
221impl fmt::Debug for CompletedExecution {
222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223 finish_capture_debug(
224 f.debug_struct("CompletedExecution")
225 .field("status", &self.status),
226 &self.stdout,
227 &self.stderr,
228 self.stdout_truncated,
229 self.stderr_truncated,
230 )
231 }
232}
233
234impl fmt::Debug for ExecutionEvidence {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 finish_capture_debug(
237 f.debug_struct("ExecutionEvidence")
238 .field("status", &self.status),
239 &self.stdout,
240 &self.stderr,
241 self.stdout_truncated,
242 self.stderr_truncated,
243 )
244 }
245}
246
247fn finish_capture_debug(
248 debug: &mut fmt::DebugStruct<'_, '_>,
249 stdout: &[u8],
250 stderr: &[u8],
251 stdout_truncated: bool,
252 stderr_truncated: bool,
253) -> fmt::Result {
254 debug
255 .field("stdout_bytes", &stdout.len())
256 .field("stderr_bytes", &stderr.len())
257 .field("stdout_truncated", &stdout_truncated)
258 .field("stderr_truncated", &stderr_truncated)
259 .finish()
260}
261
262/// Process or pipe operation that produced an IO failure.
263///
264/// These categories contain no arguments, environment or captured output.
265#[derive(Clone, Copy, Debug, Eq, PartialEq)]
266pub enum ExecutionOperation {
267 /// Create the child process in its selected working directory.
268 Spawn,
269 /// Obtain the child's pipe for explicitly supplied input.
270 StdinPipe,
271 /// Read an IO pipe's current descriptor flags.
272 ReadPipeFlags,
273 /// Enable nonblocking IO on a pipe.
274 SetPipeFlags,
275 /// Read bytes from a capture pipe.
276 ReadOutput,
277 /// Write bytes to the child's stdin pipe.
278 WriteInput,
279 /// Observe child exit, including selected group cleanup before reaping.
280 Wait,
281}
282
283impl fmt::Display for ExecutionOperation {
284 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
285 f.write_str(match self {
286 Self::Spawn => "spawn",
287 Self::StdinPipe => "stdin pipe",
288 Self::ReadPipeFlags => "read pipe flags",
289 Self::SetPipeFlags => "set pipe flags",
290 Self::ReadOutput => "read output",
291 Self::WriteInput => "write input",
292 Self::Wait => "wait",
293 })
294 }
295}
296
297/// Why an invocation failed, independently of its retained output.
298#[derive(Debug)]
299pub enum ExecutionFailure {
300 /// An explicit success check rejected the completed status in evidence.
301 ExitStatus,
302 /// Child exit or pipe EOF was not observed before the caller's deadline.
303 TimedOut,
304 /// The caller's cancellation predicate requested cleanup.
305 Cancelled,
306 /// The output observer rejected a chunk; its original sink error is retained.
307 Observer {
308 /// Stream being observed.
309 stream: OutputStream,
310 /// Caller-provided output-sink failure.
311 source: io::Error,
312 },
313 /// A stream emitted more bytes than allowed.
314 OutputLimit {
315 /// Stream whose bound was exceeded.
316 stream: OutputStream,
317 },
318 /// A process or pipe operation failed.
319 Io {
320 /// Operation category; contains no command or credential values.
321 operation: ExecutionOperation,
322 /// Underlying typed failure.
323 source: io::Error,
324 },
325 /// Retained output storage could not be allocated.
326 Allocation {
327 /// Stream being captured.
328 stream: OutputStream,
329 /// Underlying allocation failure.
330 source: std::collections::TryReserveError,
331 },
332}
333
334/// A failed invocation with bounded output and separately retained cleanup errors.
335#[derive(Debug)]
336pub struct ExecutionError {
337 /// Original failure; never replaced by a cleanup failure.
338 pub failure: ExecutionFailure,
339 /// Observed status and bounded stdout/stderr prefixes.
340 pub evidence: ExecutionEvidence,
341 /// Cleanup failures retained by the child owner, if cleanup failed.
342 pub cleanup: Option<Box<crate::child::CleanupError>>,
343}
344
345impl fmt::Display for ExecutionError {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 match &self.failure {
348 ExecutionFailure::ExitStatus => {
349 write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
350 }
351 ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
352 ExecutionFailure::Cancelled => f.write_str("tool communication cancelled"),
353 ExecutionFailure::Observer { stream, .. } => {
354 write!(f, "tool {stream:?} observer failed")
355 }
356 ExecutionFailure::OutputLimit { stream } => {
357 write!(f, "tool {stream:?} exceeded its byte limit")
358 }
359 ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
360 ExecutionFailure::Allocation { stream, .. } => {
361 write!(f, "tool {stream:?} allocation failed")
362 }
363 }?;
364 if let Some(cleanup) = &self.cleanup {
365 write!(f, "; cleanup failed: {cleanup}")?;
366 }
367 Ok(())
368 }
369}
370impl std::error::Error for ExecutionError {
371 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
372 match &self.failure {
373 ExecutionFailure::Io { source, .. } | ExecutionFailure::Observer { source, .. } => {
374 Some(source)
375 }
376 ExecutionFailure::Allocation { source, .. } => Some(source),
377 _ => None,
378 }
379 }
380}
381
382/// Executable admission or execution failed.
383#[derive(Debug)]
384pub enum ToolError {
385 /// Invalid context, authority or arguments; no new child was spawned.
386 /// Communication validation leaves an existing child and its pipes untouched.
387 InvalidInvocation(InvalidInvocation),
388 /// Filesystem path resolution or metadata failed before execution.
389 Io(io::Error),
390 /// The selected file has no Unix executable permission bits.
391 NotExecutable,
392 /// Executable identity could not be read or did not match authority.
393 Artifact(ArtifactError),
394 /// One invocation failed; includes bounded evidence.
395 Execution(Box<ExecutionError>),
396 /// Successful version stdout was not valid UTF-8.
397 VersionUtf8 {
398 /// UTF-8 validation failure.
399 source: std::str::Utf8Error,
400 /// Raw bounded version output.
401 evidence: Box<ExecutionEvidence>,
402 },
403 /// Successful trimmed version stdout did not match the selected identity,
404 /// or observation produced an empty identity.
405 VersionMismatch {
406 /// Raw bounded version output; never formatted into the error.
407 evidence: Box<ExecutionEvidence>,
408 },
409}
410
411impl ToolError {
412 /// Borrow the original bounded capture, including successful version output.
413 ///
414 /// Validation and filesystem/admission failures have no capture and return
415 /// `None`. A failed spawn retains its existing empty execution evidence.
416 /// No output is copied, decoded, logged or formatted by this accessor.
417 #[must_use]
418 pub fn evidence(&self) -> Option<&ExecutionEvidence> {
419 match self {
420 Self::Execution(error) => Some(&error.evidence),
421 Self::VersionUtf8 { evidence, .. } | Self::VersionMismatch { evidence } => {
422 Some(evidence)
423 }
424 Self::InvalidInvocation(_) | Self::Io(_) | Self::NotExecutable | Self::Artifact(_) => {
425 None
426 }
427 }
428 }
429
430 /// Borrow the original execution failure and its kill/reap outcomes, if any.
431 ///
432 /// A successful version capture rejected by admission has evidence but no
433 /// execution failure. Formatting, redaction and recovery remain caller-owned.
434 #[must_use]
435 pub fn execution_error(&self) -> Option<&ExecutionError> {
436 match self {
437 Self::Execution(error) => Some(error),
438 _ => None,
439 }
440 }
441}
442
443/// Capture one caller-configured command without performing executable admission.
444///
445/// The caller owns the program, arguments, working directory, environment and
446/// platform setup. Their [`Command`] settings are used unchanged except that
447/// stdin is set to null and stdout/stderr to pipes. Ambient environment or PATH
448/// search remains enabled if the caller's command enables it. No digest/version
449/// check, credential selection, command reconstruction or retry is performed.
450/// Use [`AdmittedTool`] when exact executable-byte/version admission is required.
451///
452/// Shares the admitted-tool execution engine: stdout/stderr are drained fairly
453/// with bounded storage, and the deadline starts immediately before spawning
454/// and extends through pipe EOF. On failure, pipes are closed and the direct
455/// child is terminated/reaped, retaining the original failure and cleanup
456/// evidence. Descendants, platform setup hooks and inherited descriptor lifetimes
457/// remain caller-owned. Spawning, setup hooks and kill/reap are synchronous and
458/// may exceed the deadline. This does not supervise a process group, roll back
459/// an external effect or make an uncertain command safe to repeat.
460///
461/// # Errors
462/// Rejects an invalid deadline before touching or spawning the command. Spawn,
463/// capture, overflow and deadline failures retain bounded evidence. Ordinary
464/// nonzero/signalled exits return [`CompletedExecution`]; select
465/// [`CompletedExecution::require_success`] when the caller requires zero exit.
466pub fn capture_command(
467 command: &mut Command,
468 limits: OutputLimits,
469) -> Result<CompletedExecution, ToolError> {
470 validate_limits(limits)?;
471 process::capture_command(
472 command,
473 limits,
474 process::CleanupScope::DirectChild,
475 true,
476 || false,
477 )
478 .map_err(|source| ToolError::Execution(Box::new(source)))
479}
480
481/// Capture one caller-configured command in a newly owned process group.
482///
483/// Uses the same bounded, fair stdout/stderr capture and deadline as
484/// [`capture_command`], with null stdin and no executable admission or retries.
485/// Preserves caller command settings except IO and process-group selection;
486/// the latter is replaced with a new owned group. On natural leader exit,
487/// timeout, overflow or IO failure, remaining group members are signalled before
488/// reaping the leader. Original failures and group/direct-child cleanup errors
489/// remain separate in [`ExecutionError`]. There is no implicit background handoff.
490///
491/// Callers must not reap the leader independently or change its group. Escaped
492/// descendants are not contained, and signalling is not proof of descendant exit
493/// or completed external effects. Synchronous spawning/setup/cleanup can exceed
494/// the deadline. Admission, budgets, inherited descriptors and recovery remain
495/// caller-owned. Use [`capture_command`] for the existing direct-child contract.
496/// # Errors
497/// Rejects invalid deadlines before modifying/spawning the command. Execution
498/// failures retain bounded output, observed status and separate cleanup errors.
499pub fn capture_group_command(
500 command: &mut Command,
501 limits: OutputLimits,
502) -> Result<CompletedExecution, ToolError> {
503 capture_group_command_with_cancellation(
504 command,
505 limits,
506 crate::child::CleanupPolicy::KillAndWait,
507 None,
508 || false,
509 )
510}
511
512/// Capture a command with caller-selected group cleanup, deadline and cancellation.
513///
514/// Shares [`capture_group_command`]'s IO, bounds, exit and cleanup contract.
515/// The callback runs before command modification/spawn and during communication;
516/// it must return promptly. A true value refuses startup or cleans up the owned
517/// group, retaining output and cleanup evidence. An expired absolute deadline
518/// refuses before command effects; a live deadline is shared across calls without
519/// restarting its allowance. If `limits.timeout` is also set, the earlier bound
520/// applies. Both bounds include callback/spawn time, but synchronous spawning and
521/// cleanup may exceed them. `None` adds no deadline. No signal handler, retry or
522/// admission policy is installed. Cleanup and limits remain caller-selected.
523///
524/// # Errors
525/// Invalid limits refuse before the callback or command effects. Cancellation,
526/// spawn and communication failures retain the existing typed execution evidence.
527pub fn capture_group_command_with_cancellation(
528 command: &mut Command,
529 limits: OutputLimits,
530 cleanup: crate::child::CleanupPolicy,
531 deadline: Option<Instant>,
532 cancelled: impl FnMut() -> bool,
533) -> Result<CompletedExecution, ToolError> {
534 validate_limits(limits)?;
535 process::capture_command(
536 command,
537 limits,
538 process::CleanupScope::ProcessGroup { cleanup, deadline },
539 true,
540 cancelled,
541 )
542 .map_err(|source| ToolError::Execution(Box::new(source)))
543}
544
545/// Run one caller-configured command in an owned process group.
546///
547/// Preserves the caller's stdin/stdout/stderr selection, including inherited
548/// progress output and file-backed streams. Only piped output is captured and
549/// bounded. Any piped stdin is closed; this entry point sends no input bytes.
550/// Group selection is replaced with a new owned group. This shares
551/// [`capture_group_command_with_cancellation`]'s pre-spawn validation,
552/// cancellation, absolute/relative deadlines and typed cleanup evidence.
553/// The caller selects budgets, cleanup and cancellation; `None` adds no deadline.
554/// Ordinary nonzero/signalled exits return [`CompletedExecution`]; callers
555/// choose [`CompletedExecution::require_success`]. There is no admission or retry.
556///
557/// # Errors
558/// Invalid limits, initial cancellation and expired deadlines refuse before
559/// modifying/spawning the command. Later failures clean up the owned group.
560pub fn run_group_command(
561 command: &mut Command,
562 limits: OutputLimits,
563 cleanup: crate::child::CleanupPolicy,
564 deadline: Option<Instant>,
565 cancelled: impl FnMut() -> bool,
566) -> Result<CompletedExecution, ToolError> {
567 validate_limits(limits)?;
568 process::capture_command(
569 command,
570 limits,
571 process::CleanupScope::ProcessGroup { cleanup, deadline },
572 false,
573 cancelled,
574 )
575 .map_err(|source| ToolError::Execution(Box::new(source)))
576}
577
578/// Successful leader disposition during [`communicate_child`].
579#[derive(Clone, Copy, Debug, Eq, PartialEq)]
580pub enum SuccessfulExit {
581 /// Clean according to child ownership before reaping, then drain captured pipes.
582 /// Direct-child ownership never signals descendants or other group members.
583 Cleanup,
584 /// Reserve the successful leader through IO completion and caller admission.
585 /// The caller must then explicitly wait, terminate or hand off the owner.
586 Retain,
587}
588
589/// Communicate once with a caller-spawned child using its configured IO.
590///
591/// Uses the capture engine to fairly drain available stdout/stderr pipes and
592/// write borrowed input without blocking reader/writer threads. The caller
593/// configures IO before [`crate::child::OwnedChild::spawn`]: absent output pipes
594/// are left alone (for example inherited progress output or files). Limits only
595/// apply to captured pipes. Do not take the child's pipes before this call.
596/// `Some(input)`, including empty input, requires piped stdin. `None` closes any
597/// available stdin pipe immediately; inherited stdin remains caller-selected.
598/// [`crate::child::OwnedChild::spawn_direct`] preserves process-group selection
599/// and limits cleanup to the direct child. Descendant-held pipes can still delay
600/// EOF; this function does not acquire descendant or terminal ownership.
601/// Stdin closes after input is written. An early broken pipe is accepted, like
602/// standard communicate semantics; command status and diagnostics remain primary.
603/// This does not guarantee the command consumed all input or applied its effects.
604///
605/// A selected deadline starts on entry, excluding earlier spawn time;
606/// `timeout: None` imposes no elapsed-time deadline. Output retention and
607/// cleanup remain active in either case. `cancelled` is
608/// polled between bounded IO steps and before returning success; it must return
609/// promptly. No signal handler, cancellation thread, retry or output decoding
610/// is installed. IO failure, cancellation, timeout and overflow
611/// close pipes and terminate/reap through the child's existing owner,
612/// retaining original failure and separate cleanup errors.
613///
614/// Completed zero/nonzero/signalled exits return a required status with all
615/// owned pipes closed. Nonzero/signalled leaders always undergo ordinary cleanup
616/// and reaping, including with [`SuccessfulExit::Retain`]; they cannot be handed off.
617/// With [`SuccessfulExit::Cleanup`],
618/// cleanup for the selected ownership scope and reaping occur as soon as child
619/// exit is observed. With [`SuccessfulExit::Retain`], the successful child remains
620/// reserved and its selected cleanup remains armed. After admitting output and checking
621/// application cancellation/deadlines, the caller must choose ordinary
622/// [`crate::child::OwnedChild::wait`] cleanup or explicit
623/// [`crate::child::OwnedChild::handoff`]. Rejected output can use `terminate`
624/// to retain cleanup evidence. Dropping the owner provides best-effort cleanup.
625/// This is not process-tree confinement or a descendant-exit barrier.
626///
627/// # Errors
628/// Invalid deadlines leave the already spawned child and its pipes untouched;
629/// the caller retains cleanup responsibility. An already terminating/reaped/transferred
630/// owner is rejected instead of reporting reserved success. Other failures return execution
631/// evidence after cleanup. Cleanup uses the policy chosen at child spawn and
632/// can exceed the communication deadline by its separate grace/reap allowance.
633pub fn communicate_child(
634 child: &mut crate::child::OwnedChild,
635 input: Option<&[u8]>,
636 limits: OutputLimits,
637 successful_exit: SuccessfulExit,
638 cancelled: impl FnMut() -> bool,
639) -> Result<CompletedExecution, ToolError> {
640 communicate_child_with_observer(child, input, limits, successful_exit, cancelled, |_, _| {
641 Ok(())
642 })
643}
644
645/// Communicate with an owned child while observing output bytes live.
646///
647/// Uses the same bounds, pipe handling and cleanup as [`communicate_child`].
648/// `output` receives nonempty borrowed chunks after retention, in order within
649/// each stream; chunk boundaries and ordering between streams are unspecified.
650/// With [`OutputLimit::Terminate`], only the retained prefix is reported before
651/// overflow fails. With [`OutputLimit::Truncate`], every drained byte is reported,
652/// while evidence retains only the selected prefix. A zero retained-byte budget
653/// can therefore forward output without storing it.
654/// Output is raw bytes, not necessarily complete lines or UTF-8.
655/// The observer returns `io::Result<()>`. Its first error stops observation,
656/// closes pipes and attempts cleanup, preserving the original sink error as
657/// [`ExecutionFailure::Observer`] alongside retained bytes and cleanup failures.
658/// A chunk is retained and any truncation recorded before observation; rejection
659/// takes precedence over a hard overflow detected in that same chunk.
660///
661/// Both callbacks run synchronously and must return promptly. `cancelled` also
662/// runs while the child is silent and may project caller-owned heartbeat events;
663/// no exact callback cadence is guaranteed. Callback time counts toward any
664/// selected communication deadline. A callback panic closes the pipes, attempts cleanup
665/// using the child's policy and resumes the original unwind, even if the caller
666/// catches it while retaining the child. Cleanup errors cannot be returned during
667/// unwind; any unreaped child remains owned for caller recovery.
668///
669/// Callers own event schemas, scheduling, output budgets and diagnostic rendering.
670/// Each stream explicitly selects hard overflow or truncation-and-continue.
671///
672/// # Panics
673/// Resumes callback panics after attempting cleanup with the selected policy.
674///
675/// # Errors
676/// Returns the same validation and execution errors as [`communicate_child`].
677pub fn communicate_child_with_observer(
678 child: &mut crate::child::OwnedChild,
679 input: Option<&[u8]>,
680 limits: OutputLimits,
681 successful_exit: SuccessfulExit,
682 cancelled: impl FnMut() -> bool,
683 output: impl FnMut(OutputStream, &[u8]) -> io::Result<()>,
684) -> Result<CompletedExecution, ToolError> {
685 validate_limits(limits)?;
686 process::communicate(child, input, limits, successful_exit, cancelled, output)
687 .map_err(|source| ToolError::Execution(Box::new(source)))
688}
689
690/// Complete a child after a caller has read control frames from its stdout.
691///
692/// Hand off the original buffered pipe without converting it to its raw inner
693/// pipe: unread prefetched bytes count against the same output budget as later
694/// bytes. `None` uses any stdout still held by the child, or captures no stdout
695/// if the caller consumed it completely. Stdin is closed; stderr is taken from
696/// the child and both streams use the existing nonblocking capture engine.
697/// No child is respawned and no retry occurs.
698///
699/// Unlike [`communicate_child`], a previously reaped/terminated child may be
700/// drained using its cached status; it is never signalled again. Transferred
701/// ownership refuses. Successful children use ordinary cleanup and cannot be
702/// handed off. Callers retain pipe provenance, protocol decoding, limits and
703/// cancellation. A selected timeout starts on entry; no default deadline is added.
704///
705/// # Errors
706/// Returns validation, capture and cleanup evidence as [`communicate_child`].
707/// Invalid limits do not touch the child; the passed reader is consumed/dropped.
708pub fn drain_child_output(
709 child: &mut crate::child::OwnedChild,
710 stdout: Option<std::io::BufReader<std::process::ChildStdout>>,
711 limits: OutputLimits,
712 cancelled: impl FnMut() -> bool,
713) -> Result<CompletedExecution, ToolError> {
714 validate_limits(limits)?;
715 process::drain_output(child, stdout, limits, cancelled)
716 .map_err(|source| ToolError::Execution(Box::new(source)))
717}
718
719impl fmt::Display for ToolError {
720 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
721 match self {
722 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
723 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
724 Self::NotExecutable => f.write_str("tool file is not executable"),
725 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
726 Self::Execution(source) => source.fmt(f),
727 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
728 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
729 }
730 }
731}
732impl std::error::Error for ToolError {
733 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
734 match self {
735 Self::Io(source) => Some(source),
736 Self::Artifact(source) => Some(source),
737 Self::Execution(source) => Some(source.as_ref()),
738 Self::VersionUtf8 { source, .. } => Some(source),
739 _ => None,
740 }
741 }
742}
743
744/// An executable with retained byte and version identities.
745///
746/// [`Self::admit`] requires a consumer-supplied digest. [`Self::admit_version`]
747/// records the installed identity without authenticating it against a pin.
748/// [`Self::observe_version`] records both identities without an expected version.
749/// Every execution rechecks digest/permission before spawning. Consumers must
750/// exclude concurrent writers to the executable and its parent directories:
751/// filesystem checks and `exec` are separate operations. This is not a file
752/// capability or verification of dynamic libraries, interpreters, or descendants.
753pub struct AdmittedTool {
754 path: PathBuf,
755 identity: ArtifactIdentity,
756 executable_bytes: u64,
757 version_identity: String,
758 no_follow: bool,
759}
760
761impl AdmittedTool {
762 /// Admit exact executable bytes and require the selected version identity.
763 ///
764 /// `expected_version` is exact UTF-8 stdout after Unicode whitespace trimming.
765 ///
766 /// # Errors
767 /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
768 /// failures, non-UTF-8 stdout, and a different successful version identity.
769 pub fn admit(
770 spec: &VersionSpec<'_>,
771 expected: Sha256Digest,
772 expected_version: &str,
773 context: &ExecutionContext<'_>,
774 limits: OutputLimits,
775 ) -> Result<Self, ToolError> {
776 Self::admit_observation(
777 spec,
778 Admission::Pinned {
779 digest: expected,
780 version: expected_version,
781 no_follow: false,
782 },
783 context,
784 limits,
785 )
786 }
787
788 /// Admit exact bytes without following the executable's final-component symlink.
789 ///
790 /// Performs one bounded no-follow hash before the version command. Parents
791 /// and concurrent writers remain caller-owned. The retained canonical path
792 /// also rejects final-component links during subsequent [`Self::run`] calls.
793 ///
794 /// # Errors
795 /// Returns the same admission failures as [`Self::admit`], additionally
796 /// refusing final-component symbolic links and special files before execution.
797 pub fn admit_no_follow(
798 spec: &VersionSpec<'_>,
799 expected: Sha256Digest,
800 expected_version: &str,
801 context: &ExecutionContext<'_>,
802 limits: OutputLimits,
803 ) -> Result<Self, ToolError> {
804 Self::admit_observation(
805 spec,
806 Admission::Pinned {
807 digest: expected,
808 version: expected_version,
809 no_follow: true,
810 },
811 context,
812 limits,
813 )
814 }
815
816 /// Admit an exact version and record the caller-trusted installed bytes.
817 ///
818 /// `expected_version` is exact UTF-8 stdout after Unicode whitespace trimming.
819 ///
820 /// For tools built locally, no portable published digest may exist. This
821 /// entry hashes the executable within the supplied budget before running
822 /// the version command. The resulting [`Self::identity`] is an observation,
823 /// not a trusted published pin. All later runs reject changed bytes using
824 /// the same verification and capture engine as [`Self::admit`].
825 ///
826 /// The caller must trust the installation before admission: the version
827 /// command executes those bytes. Exact version output does not establish
828 /// authenticity. No version ranges, tool installation or PATH search occur.
829 /// The caller must exclude concurrent executable/directory writers.
830 ///
831 /// # Errors
832 /// Rejects invalid inputs, non-executable or oversized files, process
833 /// failures, non-UTF-8 stdout and a different successful version identity.
834 pub fn admit_version(
835 spec: &VersionSpec<'_>,
836 expected_version: &str,
837 context: &ExecutionContext<'_>,
838 limits: OutputLimits,
839 ) -> Result<Self, ToolError> {
840 Self::admit_observation(spec, Admission::Version(expected_version), context, limits)
841 }
842
843 /// Record installed bytes and their version with one version invocation.
844 ///
845 /// This is suitable for provenance of a caller-trusted installation when no
846 /// expected version is selected. Hashing precedes version dispatch; later
847 /// runs recheck that byte identity. The complete UTF-8 version stdout must
848 /// be nonempty after trimming. It does not authenticate the installation.
849 /// Consumers must exclude concurrent executable/directory writers and own
850 /// trust, output budgets, deadlines and environment, as for [`Self::admit_version`].
851 ///
852 /// # Errors
853 /// Rejects invalid inputs, non-executable or oversized files, process
854 /// failures, truncated output, non-UTF-8 stdout and an empty version.
855 /// An empty observation returns [`ToolError::VersionMismatch`] with the
856 /// original capture, just as an unmatched expected version does.
857 pub fn observe_version(
858 spec: &VersionSpec<'_>,
859 context: &ExecutionContext<'_>,
860 limits: OutputLimits,
861 ) -> Result<Self, ToolError> {
862 Self::admit_observation(spec, Admission::Observe, context, limits)
863 }
864
865 fn admit_observation(
866 spec: &VersionSpec<'_>,
867 admission: Admission<'_>,
868 context: &ExecutionContext<'_>,
869 limits: OutputLimits,
870 ) -> Result<Self, ToolError> {
871 let (expected, expected_version, no_follow) = match admission {
872 Admission::Pinned {
873 digest,
874 version,
875 no_follow,
876 } => (Some(digest), Some(version), no_follow),
877 Admission::Version(version) => (None, Some(version), false),
878 Admission::Observe => (None, None, false),
879 };
880 if !spec.executable.is_absolute() {
881 return Err(ToolError::InvalidInvocation(
882 InvalidInvocation::ExecutablePath,
883 ));
884 }
885 if expected_version.is_some_and(|version| version.is_empty() || version.trim() != version) {
886 return Err(ToolError::InvalidInvocation(
887 InvalidInvocation::VersionIdentity,
888 ));
889 }
890 validate_invocation(spec.version_arguments, context, limits)?;
891 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
892 let identity = verify_executable(
893 if no_follow { spec.executable } else { &path },
894 spec.executable_bytes,
895 expected,
896 no_follow,
897 )?;
898 let evidence = process::capture(&path, spec.version_arguments, context, limits)
899 .map_err(|source| ToolError::Execution(Box::new(source)))?
900 .require_success()?
901 .require_complete()?;
902 let version = match std::str::from_utf8(&evidence.stdout) {
903 Ok(version) => version.trim(),
904 Err(source) => {
905 return Err(ToolError::VersionUtf8 {
906 source,
907 evidence: Box::new(evidence.into()),
908 });
909 }
910 };
911 if version.is_empty() || expected_version.is_some_and(|expected| version != expected) {
912 return Err(ToolError::VersionMismatch {
913 evidence: Box::new(evidence.into()),
914 });
915 }
916 Ok(Self {
917 path,
918 identity,
919 executable_bytes: spec.executable_bytes,
920 version_identity: version.to_owned(),
921 no_follow,
922 })
923 }
924
925 /// Canonical absolute path selected during admission.
926 #[must_use]
927 pub fn path(&self) -> &Path {
928 &self.path
929 }
930
931 /// Retained raw executable identity.
932 ///
933 /// With [`Self::admit_version`] or [`Self::observe_version`], this is an observed identity, not proof of
934 /// a published binary pin or trusted installation provenance.
935 #[must_use]
936 pub const fn identity(&self) -> ArtifactIdentity {
937 self.identity
938 }
939
940 /// Successfully observed, trimmed version identity.
941 #[must_use]
942 pub fn version_identity(&self) -> &str {
943 &self.version_identity
944 }
945
946 /// Run once with a cleared, explicitly supplied environment and null stdin.
947 ///
948 /// Stdout/stderr are drained fairly through nonblocking pipes without reader
949 /// threads. On overflow/deadline the direct child is killed and reaped; pipe
950 /// handles are closed without waiting for descendants to close their copies.
951 /// Descendant processes remain caller-owned. This must not be interpreted
952 /// as a rollback or safe automatic retry of a command with external effects.
953 /// Nonzero/signalled exits are completed results. Call
954 /// [`CompletedExecution::require_success`] before success-only interpretation.
955 ///
956 /// # Errors
957 /// Returns invalid-input or identity failures before execution, or an
958 /// execution failure retaining bounded prefixes and cleanup outcomes.
959 pub fn run(
960 &self,
961 arguments: &[OsString],
962 context: &ExecutionContext<'_>,
963 limits: OutputLimits,
964 ) -> Result<CompletedExecution, ToolError> {
965 validate_invocation(arguments, context, limits)?;
966 verify_executable(
967 &self.path,
968 self.executable_bytes,
969 Some(self.identity.sha256),
970 self.no_follow,
971 )?;
972 process::capture(&self.path, arguments, context, limits)
973 .map_err(|source| ToolError::Execution(Box::new(source)))
974 }
975}
976
977#[derive(Clone, Copy)]
978enum Admission<'a> {
979 Pinned {
980 digest: Sha256Digest,
981 version: &'a str,
982 no_follow: bool,
983 },
984 Version(&'a str),
985 Observe,
986}
987
988fn verify_executable(
989 path: &Path,
990 limit: u64,
991 expected: Option<Sha256Digest>,
992 no_follow: bool,
993) -> Result<ArtifactIdentity, ToolError> {
994 let (actual, metadata) = if no_follow {
995 hash_file_no_follow_with_metadata(path, limit)
996 } else {
997 hash_file_with_metadata(path, limit)
998 }
999 .map_err(ToolError::Artifact)?;
1000 if let Some(expected) = expected {
1001 actual.verify(expected).map_err(ToolError::Artifact)?;
1002 }
1003 if metadata.permissions().mode() & 0o111 == 0 {
1004 return Err(ToolError::NotExecutable);
1005 }
1006 Ok(actual)
1007}
1008
1009fn validate_invocation(
1010 arguments: &[OsString],
1011 context: &ExecutionContext<'_>,
1012 limits: OutputLimits,
1013) -> Result<(), ToolError> {
1014 let reject = |input| ToolError::InvalidInvocation(input);
1015 if !context.current_dir.is_absolute() {
1016 return Err(reject(InvalidInvocation::WorkingDirectory));
1017 }
1018 validate_limits(limits)?;
1019 for (index, argument) in arguments.iter().enumerate() {
1020 if argument.as_bytes().contains(&0) {
1021 return Err(reject(InvalidInvocation::Argument { index }));
1022 }
1023 }
1024 for (index, (key, value)) in context.environment.iter().enumerate() {
1025 if key.is_empty()
1026 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
1027 || context.environment[..index]
1028 .iter()
1029 .any(|(earlier, _)| earlier == key)
1030 {
1031 return Err(reject(InvalidInvocation::EnvironmentName { index }));
1032 }
1033 if value.as_bytes().contains(&0) {
1034 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
1035 }
1036 }
1037 Ok(())
1038}
1039
1040fn validate_limits(limits: OutputLimits) -> Result<(), ToolError> {
1041 if limits.timeout.is_some_and(|timeout| {
1042 timeout.is_zero() || std::time::Instant::now().checked_add(timeout).is_none()
1043 }) {
1044 return Err(ToolError::InvalidInvocation(InvalidInvocation::Deadline));
1045 }
1046 Ok(())
1047}