Skip to main content

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