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