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