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