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