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;
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/// Caller-selected stdout/stderr storage bounds and capture deadline.
26#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct OutputLimits {
28    /// Maximum retained stdout bytes; zero permits only empty stdout.
29    pub stdout_bytes: usize,
30    /// Maximum retained stderr bytes; zero permits only empty stderr.
31    pub stderr_bytes: usize,
32    /// Positive deadline from immediately before spawning through output EOF,
33    /// or from entry to [`communicate_child`] for an already spawned child.
34    /// Verification reads, spawning syscalls and kill/reap may take longer.
35    pub timeout: Duration,
36}
37
38/// Captured-output bounds and an optional deadline for caller-owned communication.
39///
40/// Use with [`communicate_child`] or [`communicate_child_with_observer`]. An
41/// existing [`OutputLimits`] converts to these limits with its finite deadline.
42/// Capture and executable-admission APIs continue to require `OutputLimits`.
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub struct CommunicationLimits {
45    /// Maximum retained stdout bytes; zero permits only empty stdout.
46    pub stdout_bytes: usize,
47    /// Maximum retained stderr bytes; zero permits only empty stderr.
48    pub stderr_bytes: usize,
49    /// Positive deadline measured from communication entry, or no elapsed-time limit.
50    ///
51    /// `None` still enforces output bounds, polls cancellation and cleans up on
52    /// failure. It can wait indefinitely for child exit or pipe EOF, including
53    /// descendant-held pipes with [`SuccessfulExit::Retain`]. Choose cancellation
54    /// and successful-exit policy deliberately. Cleanup timing remains governed
55    /// by the child's [`crate::child::CleanupPolicy`].
56    pub timeout: Option<Duration>,
57}
58
59impl From<OutputLimits> for CommunicationLimits {
60    fn from(limits: OutputLimits) -> Self {
61        Self {
62            stdout_bytes: limits.stdout_bytes,
63            stderr_bytes: limits.stderr_bytes,
64            timeout: Some(limits.timeout),
65        }
66    }
67}
68
69/// Explicit process context. The child's inherited environment is cleared.
70///
71/// No ambient PATH, HOME or credentials are added. Consumers must include any
72/// environment needed by their tool. This type intentionally has no `Debug`
73/// implementation because its values may contain credentials.
74pub struct ExecutionContext<'a> {
75    /// Absolute current directory for the child.
76    pub current_dir: &'a Path,
77    /// Complete environment; names must be nonempty, unique, and contain no
78    /// `=` or NUL bytes. Values must contain no NUL bytes.
79    pub environment: &'a [(OsString, OsString)],
80}
81
82/// Exact executable and version authority selected by a consumer.
83pub struct ToolSpec<'a> {
84    /// Absolute path in a caller-controlled filesystem; no PATH search occurs.
85    pub executable: &'a Path,
86    /// Admitted SHA-256 of the executable bytes.
87    pub sha256: Sha256Digest,
88    /// Maximum executable bytes read during every verification.
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/// Exact version authority for a caller-trusted installed executable.
97///
98/// Unlike [`ToolSpec`], this supplies no trusted executable digest. Admission
99/// records the installed bytes for later drift checks; a matching version is
100/// not authentication of those bytes. Consumers own installation provenance.
101pub struct VersionSpec<'a> {
102    /// Absolute path in a caller-controlled filesystem; no PATH search occurs.
103    pub executable: &'a Path,
104    /// Maximum executable bytes read during admission and every later run.
105    pub executable_bytes: u64,
106    /// Exact argument vector used to observe version identity.
107    pub version_arguments: &'a [OsString],
108    /// Required UTF-8 stdout after Unicode whitespace is trimmed at both ends.
109    pub version_identity: &'a str,
110}
111
112/// Invocation parameters rejected before dispatch or communication.
113/// An already spawned child remains owned and untouched by this validation.
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub enum InvalidInvocation {
116    /// Tool paths must be absolute.
117    ExecutablePath,
118    /// Working directories must be absolute.
119    WorkingDirectory,
120    /// Deadlines must be positive and representable by the host clock.
121    Deadline,
122    /// Version identity must be nonempty and already trimmed.
123    VersionIdentity,
124    /// An argument contains a NUL byte.
125    Argument {
126        /// Argument position.
127        index: usize,
128    },
129    /// An environment name is empty, contains `=`/NUL, or duplicates an earlier name.
130    EnvironmentName {
131        /// Environment entry position.
132        index: usize,
133    },
134    /// An environment value contains a NUL byte.
135    EnvironmentValue {
136        /// Environment entry position.
137        index: usize,
138    },
139}
140
141/// A captured output stream.
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
143pub enum OutputStream {
144    /// Standard output.
145    Stdout,
146    /// Standard error.
147    Stderr,
148}
149
150/// Bounded evidence from one invocation, including interrupted invocations.
151///
152/// Bytes are available explicitly to the caller; formatting prints only lengths
153/// and status. Error messages never include command arguments or environment.
154#[derive(Default)]
155pub struct ExecutionEvidence {
156    /// Observed direct-child status. Communication can reserve a successful
157    /// leader without reaping it. Status does not prove external effects absent.
158    pub status: Option<ExitStatus>,
159    /// Retained stdout prefix, bounded by the selected limit.
160    pub stdout: Vec<u8>,
161    /// Retained stderr prefix, bounded by the selected limit.
162    pub stderr: Vec<u8>,
163    /// More stdout bytes were observed than could be retained.
164    pub stdout_truncated: bool,
165    /// More stderr bytes were observed than could be retained.
166    pub stderr_truncated: bool,
167}
168
169impl fmt::Debug for ExecutionEvidence {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.debug_struct("ExecutionEvidence")
172            .field("status", &self.status)
173            .field("stdout_bytes", &self.stdout.len())
174            .field("stderr_bytes", &self.stderr.len())
175            .field("stdout_truncated", &self.stdout_truncated)
176            .field("stderr_truncated", &self.stderr_truncated)
177            .finish()
178    }
179}
180
181/// Process or pipe operation that produced an IO failure.
182///
183/// These categories contain no arguments, environment or captured output.
184#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum ExecutionOperation {
186    /// Create the child process in its selected working directory.
187    Spawn,
188    /// Obtain the child's pipe for explicitly supplied input.
189    StdinPipe,
190    /// Read an IO pipe's current descriptor flags.
191    ReadPipeFlags,
192    /// Enable nonblocking IO on a pipe.
193    SetPipeFlags,
194    /// Read bytes from a capture pipe.
195    ReadOutput,
196    /// Write bytes to the child's stdin pipe.
197    WriteInput,
198    /// Observe child exit, including selected group cleanup before reaping.
199    Wait,
200}
201
202impl fmt::Display for ExecutionOperation {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        f.write_str(match self {
205            Self::Spawn => "spawn",
206            Self::StdinPipe => "stdin pipe",
207            Self::ReadPipeFlags => "read pipe flags",
208            Self::SetPipeFlags => "set pipe flags",
209            Self::ReadOutput => "read output",
210            Self::WriteInput => "write input",
211            Self::Wait => "wait",
212        })
213    }
214}
215
216/// Why an invocation failed, independently of its retained output.
217#[derive(Debug)]
218pub enum ExecutionFailure {
219    /// The direct child completed unsuccessfully; its status is in evidence.
220    ExitStatus,
221    /// Child exit or pipe EOF was not observed before the caller's deadline.
222    TimedOut,
223    /// The caller's cancellation predicate requested cleanup.
224    Cancelled,
225    /// A stream emitted more bytes than allowed.
226    OutputLimit {
227        /// Stream whose bound was exceeded.
228        stream: OutputStream,
229    },
230    /// A process or pipe operation failed.
231    Io {
232        /// Operation category; contains no command or credential values.
233        operation: ExecutionOperation,
234        /// Underlying typed failure.
235        source: io::Error,
236    },
237    /// Retained output storage could not be allocated.
238    Allocation {
239        /// Stream being captured.
240        stream: OutputStream,
241        /// Underlying allocation failure.
242        source: std::collections::TryReserveError,
243    },
244}
245
246/// A failed invocation with bounded output and separately retained cleanup errors.
247#[derive(Debug)]
248pub struct ExecutionError {
249    /// Original failure; never replaced by a cleanup failure.
250    pub failure: ExecutionFailure,
251    /// Observed status and bounded stdout/stderr prefixes.
252    pub evidence: ExecutionEvidence,
253    /// Failure signalling TERM under the caller-selected child cleanup policy.
254    pub term_error: Option<io::Error>,
255    /// Failure signalling KILL to an owned process group; absent in direct capture.
256    pub group_error: Option<io::Error>,
257    /// Failure to terminate the direct child, if termination was needed.
258    pub kill_error: Option<io::Error>,
259    /// Failure to reap the direct child, if reaping was needed.
260    pub wait_error: Option<io::Error>,
261}
262
263impl fmt::Display for ExecutionError {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match &self.failure {
266            ExecutionFailure::ExitStatus => {
267                write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
268            }
269            ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
270            ExecutionFailure::Cancelled => f.write_str("tool communication cancelled"),
271            ExecutionFailure::OutputLimit { stream } => {
272                write!(f, "tool {stream:?} exceeded its byte limit")
273            }
274            ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
275            ExecutionFailure::Allocation { stream, .. } => {
276                write!(f, "tool {stream:?} allocation failed")
277            }
278        }
279    }
280}
281impl std::error::Error for ExecutionError {
282    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
283        match &self.failure {
284            ExecutionFailure::Io { source, .. } => Some(source),
285            ExecutionFailure::Allocation { source, .. } => Some(source),
286            _ => None,
287        }
288    }
289}
290
291/// Executable admission or execution failed.
292#[derive(Debug)]
293pub enum ToolError {
294    /// Invalid context, authority or arguments; no new child was spawned.
295    /// Communication validation leaves an existing child and its pipes untouched.
296    InvalidInvocation(InvalidInvocation),
297    /// Filesystem path resolution or metadata failed before execution.
298    Io(io::Error),
299    /// The selected file has no Unix executable permission bits.
300    NotExecutable,
301    /// Executable identity could not be read or did not match authority.
302    Artifact(ArtifactError),
303    /// One invocation failed; includes bounded evidence.
304    Execution(Box<ExecutionError>),
305    /// Successful version stdout was not valid UTF-8.
306    VersionUtf8 {
307        /// UTF-8 validation failure.
308        source: std::str::Utf8Error,
309        /// Raw bounded version output.
310        evidence: Box<ExecutionEvidence>,
311    },
312    /// Successful trimmed version stdout did not match the selected identity.
313    VersionMismatch {
314        /// Raw bounded version output; never formatted into the error.
315        evidence: Box<ExecutionEvidence>,
316    },
317}
318
319impl ToolError {
320    /// Borrow the original bounded capture, including successful version output.
321    ///
322    /// Validation and filesystem/admission failures have no capture and return
323    /// `None`. A failed spawn retains its existing empty execution evidence.
324    /// No output is copied, decoded, logged or formatted by this accessor.
325    #[must_use]
326    pub fn evidence(&self) -> Option<&ExecutionEvidence> {
327        match self {
328            Self::Execution(error) => Some(&error.evidence),
329            Self::VersionUtf8 { evidence, .. } | Self::VersionMismatch { evidence } => {
330                Some(evidence)
331            }
332            Self::InvalidInvocation(_) | Self::Io(_) | Self::NotExecutable | Self::Artifact(_) => {
333                None
334            }
335        }
336    }
337
338    /// Borrow the original execution failure and its kill/reap outcomes, if any.
339    ///
340    /// A successful version capture rejected by admission has evidence but no
341    /// execution failure. Formatting, redaction and recovery remain caller-owned.
342    #[must_use]
343    pub fn execution_error(&self) -> Option<&ExecutionError> {
344        match self {
345            Self::Execution(error) => Some(error),
346            _ => None,
347        }
348    }
349}
350
351/// Capture one caller-configured command without performing executable admission.
352///
353/// The caller owns the program, arguments, working directory, environment and
354/// platform setup. Their [`Command`] settings are used unchanged except that
355/// stdin is set to null and stdout/stderr to pipes. Ambient environment or PATH
356/// search remains enabled if the caller's command enables it. No digest/version
357/// check, credential selection, command reconstruction or retry is performed.
358/// Use [`AdmittedTool`] when exact executable-byte/version admission is required.
359///
360/// Shares the admitted-tool execution engine: stdout/stderr are drained fairly
361/// with bounded storage, and the deadline starts immediately before spawning
362/// and extends through pipe EOF. On failure, pipes are closed and the direct
363/// child is terminated/reaped, retaining the original failure and cleanup
364/// evidence. Descendants, platform setup hooks and inherited descriptor lifetimes
365/// remain caller-owned. Spawning, setup hooks and kill/reap are synchronous and
366/// may exceed the deadline. This does not supervise a process group, roll back
367/// an external effect or make an uncertain command safe to repeat.
368///
369/// # Errors
370/// Rejects an invalid deadline before touching or spawning the command. Spawn,
371/// capture, nonzero exit, overflow and deadline failures retain bounded evidence.
372pub fn capture_command(
373    command: &mut Command,
374    limits: OutputLimits,
375) -> Result<ExecutionEvidence, ToolError> {
376    validate_limits(limits)?;
377    process::capture_command(command, limits, process::CleanupScope::DirectChild)
378        .map_err(|source| ToolError::Execution(Box::new(source)))
379}
380
381/// Capture one caller-configured command in a newly owned process group.
382///
383/// Uses the same bounded, fair stdout/stderr capture and deadline as
384/// [`capture_command`], with null stdin and no executable admission or retries.
385/// Preserves caller command settings except IO and process-group selection;
386/// the latter is replaced with a new owned group. On natural leader exit,
387/// timeout, overflow or IO failure, remaining group members are signalled before
388/// reaping the leader. Original failures and group/direct-child cleanup errors
389/// remain separate in [`ExecutionError`]. There is no implicit background handoff.
390///
391/// Callers must not reap the leader independently or change its group. Escaped
392/// descendants are not contained, and signalling is not proof of descendant exit
393/// or completed external effects. Synchronous spawning/setup/cleanup can exceed
394/// the deadline. Admission, budgets, inherited descriptors and recovery remain
395/// caller-owned. Use [`capture_command`] for the existing direct-child contract.
396/// # Errors
397/// Rejects invalid deadlines before modifying/spawning the command. Execution
398/// failures retain bounded output, observed status and separate cleanup errors.
399pub fn capture_group_command(
400    command: &mut Command,
401    limits: OutputLimits,
402) -> Result<ExecutionEvidence, ToolError> {
403    validate_limits(limits)?;
404    process::capture_command(command, limits, process::CleanupScope::ProcessGroup)
405        .map_err(|source| ToolError::Execution(Box::new(source)))
406}
407
408/// Successful leader disposition during [`communicate_child`].
409#[derive(Clone, Copy, Debug, Eq, PartialEq)]
410pub enum SuccessfulExit {
411    /// Clean according to child ownership before reaping, then drain captured pipes.
412    /// Direct-child ownership never signals descendants or other group members.
413    Cleanup,
414    /// Reserve the successful leader through IO completion and caller admission.
415    /// The caller must then explicitly wait, terminate or hand off the owner.
416    Retain,
417}
418
419/// Communicate once with a caller-spawned child using its configured IO.
420///
421/// Uses the capture engine to fairly drain available stdout/stderr pipes and
422/// write borrowed input without blocking reader/writer threads. The caller
423/// configures IO before [`crate::child::OwnedChild::spawn`]: absent output pipes
424/// are left alone (for example inherited progress output or files). Limits only
425/// apply to captured pipes. Do not take the child's pipes before this call.
426/// `Some(input)`, including empty input, requires piped stdin. `None` closes any
427/// available stdin pipe immediately; inherited stdin remains caller-selected.
428/// [`crate::child::OwnedChild::spawn_direct`] preserves process-group selection
429/// and limits cleanup to the direct child. Descendant-held pipes can still delay
430/// EOF; this function does not acquire descendant or terminal ownership.
431/// Stdin closes after input is written. An early broken pipe is accepted, like
432/// standard communicate semantics; command status and diagnostics remain primary.
433/// This does not guarantee the command consumed all input or applied its effects.
434///
435/// Accepts finite [`OutputLimits`] or explicit [`CommunicationLimits`]. A selected
436/// deadline starts on entry, excluding earlier spawn time; `timeout: None` in
437/// `CommunicationLimits` imposes no elapsed-time deadline. Output bounds and
438/// cleanup remain active in either case. `cancelled` is
439/// polled between bounded IO steps and before returning success; it must return
440/// promptly. No signal handler, cancellation thread, retry or output decoding
441/// is installed. IO failure, cancellation, timeout, overflow and unsuccessful
442/// exit close pipes and terminate/reap through the child's existing owner,
443/// retaining original failure and separate cleanup errors.
444///
445/// On success, all owned pipes are closed. With [`SuccessfulExit::Cleanup`],
446/// cleanup for the selected ownership scope and reaping occur as soon as child
447/// exit is observed. With [`SuccessfulExit::Retain`], the successful child remains
448/// reserved and its selected cleanup remains armed. After admitting output and checking
449/// application cancellation/deadlines, the caller must choose ordinary
450/// [`crate::child::OwnedChild::wait`] cleanup or explicit
451/// [`crate::child::OwnedChild::handoff`]. Rejected output can use `terminate`
452/// to retain cleanup evidence. Dropping the owner provides best-effort cleanup.
453/// This is not process-tree confinement or a descendant-exit barrier.
454///
455/// # Errors
456/// Invalid deadlines leave the already spawned child and its pipes untouched;
457/// the caller retains cleanup responsibility. An already terminating/reaped/transferred
458/// owner is rejected instead of reporting reserved success. Other failures return execution
459/// evidence after cleanup. Cleanup uses the policy chosen at child spawn and
460/// can exceed the communication deadline by its separate grace/reap allowance.
461pub fn communicate_child(
462    child: &mut crate::child::OwnedChild,
463    input: Option<&[u8]>,
464    limits: impl Into<CommunicationLimits>,
465    successful_exit: SuccessfulExit,
466    cancelled: impl FnMut() -> bool,
467) -> Result<ExecutionEvidence, ToolError> {
468    communicate_child_with_observer(child, input, limits, successful_exit, cancelled, |_, _| {})
469}
470
471/// Communicate with an owned child while observing retained output bytes live.
472///
473/// Uses the same bounds, pipe handling and cleanup as [`communicate_child`].
474/// `output` receives nonempty borrowed chunks after retention, in order within
475/// each stream; chunk boundaries and ordering between streams are unspecified.
476/// The bytes concatenate to the corresponding returned evidence, including the
477/// retained prefix on overflow. Bytes beyond a selected limit are never reported.
478/// Output is raw bytes, not necessarily complete lines or UTF-8.
479///
480/// Both callbacks run synchronously and must return promptly. `cancelled` also
481/// runs while the child is silent and may project caller-owned heartbeat events;
482/// no exact callback cadence is guaranteed. Callback time counts toward any
483/// selected communication deadline. A callback panic closes the pipes, attempts cleanup
484/// using the child's policy and resumes the original unwind, even if the caller
485/// catches it while retaining the child. Cleanup errors cannot be returned during
486/// unwind; any unreaped child remains owned for caller recovery.
487///
488/// Callers own event schemas, scheduling, output budgets and diagnostic rendering.
489/// Observation does not change hard overflow into truncation-and-continue.
490///
491/// # Panics
492/// Resumes callback panics after attempting cleanup with the selected policy.
493///
494/// # Errors
495/// Returns the same validation and execution errors as [`communicate_child`].
496pub fn communicate_child_with_observer(
497    child: &mut crate::child::OwnedChild,
498    input: Option<&[u8]>,
499    limits: impl Into<CommunicationLimits>,
500    successful_exit: SuccessfulExit,
501    cancelled: impl FnMut() -> bool,
502    output: impl FnMut(OutputStream, &[u8]),
503) -> Result<ExecutionEvidence, ToolError> {
504    let limits = limits.into();
505    validate_limits(limits)?;
506    process::communicate(child, input, limits, successful_exit, cancelled, output)
507        .map_err(|source| ToolError::Execution(Box::new(source)))
508}
509
510impl fmt::Display for ToolError {
511    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
512        match self {
513            Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
514            Self::Io(_) => f.write_str("tool filesystem inspection failed"),
515            Self::NotExecutable => f.write_str("tool file is not executable"),
516            Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
517            Self::Execution(source) => source.fmt(f),
518            Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
519            Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
520        }
521    }
522}
523impl std::error::Error for ToolError {
524    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
525        match self {
526            Self::Io(source) => Some(source),
527            Self::Artifact(source) => Some(source),
528            Self::Execution(source) => Some(source.as_ref()),
529            Self::VersionUtf8 { source, .. } => Some(source),
530            _ => None,
531        }
532    }
533}
534
535/// An executable with a retained byte identity and an admitted exact version.
536///
537/// [`Self::admit`] requires a consumer-supplied digest. [`Self::admit_version`]
538/// records the installed identity without authenticating it against a pin.
539/// Every execution rechecks digest/permission before spawning. Consumers must
540/// exclude concurrent writers to the executable and its parent directories:
541/// filesystem checks and `exec` are separate operations. This is not a file
542/// capability or verification of dynamic libraries, interpreters, or descendants.
543pub struct AdmittedTool {
544    path: PathBuf,
545    identity: ArtifactIdentity,
546    executable_bytes: u64,
547    version_identity: String,
548}
549
550impl AdmittedTool {
551    /// Admit exact executable bytes before invoking the selected version command.
552    ///
553    /// # Errors
554    /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
555    /// failures, non-UTF-8 stdout, and a different successful version identity.
556    pub fn admit(
557        spec: &ToolSpec<'_>,
558        context: &ExecutionContext<'_>,
559        limits: OutputLimits,
560    ) -> Result<Self, ToolError> {
561        Self::admit_with_digest(
562            &VersionSpec {
563                executable: spec.executable,
564                executable_bytes: spec.executable_bytes,
565                version_arguments: spec.version_arguments,
566                version_identity: spec.version_identity,
567            },
568            Some(spec.sha256),
569            context,
570            limits,
571        )
572    }
573
574    /// Admit an exact version and record the caller-trusted installed bytes.
575    ///
576    /// For tools built locally, no portable published digest may exist. This
577    /// entry hashes the executable within the supplied budget before running
578    /// the version command. The resulting [`Self::identity`] is an observation,
579    /// not a trusted published pin. All later runs reject changed bytes using
580    /// the same verification and capture engine as [`Self::admit`].
581    ///
582    /// The caller must trust the installation before admission: the version
583    /// command executes those bytes. Exact version output does not establish
584    /// authenticity. No version ranges, tool installation or PATH search occur.
585    /// The caller must exclude concurrent executable/directory writers.
586    ///
587    /// # Errors
588    /// Rejects invalid inputs, non-executable or oversized files, process
589    /// failures, non-UTF-8 stdout and a different successful version identity.
590    pub fn admit_version(
591        spec: &VersionSpec<'_>,
592        context: &ExecutionContext<'_>,
593        limits: OutputLimits,
594    ) -> Result<Self, ToolError> {
595        Self::admit_with_digest(spec, None, context, limits)
596    }
597
598    fn admit_with_digest(
599        spec: &VersionSpec<'_>,
600        expected: Option<Sha256Digest>,
601        context: &ExecutionContext<'_>,
602        limits: OutputLimits,
603    ) -> Result<Self, ToolError> {
604        if !spec.executable.is_absolute() {
605            return Err(ToolError::InvalidInvocation(
606                InvalidInvocation::ExecutablePath,
607            ));
608        }
609        if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
610        {
611            return Err(ToolError::InvalidInvocation(
612                InvalidInvocation::VersionIdentity,
613            ));
614        }
615        validate_invocation(spec.version_arguments, context, limits)?;
616        let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
617        let identity = verify_executable(&path, spec.executable_bytes, expected)?;
618        let evidence = process::capture(&path, spec.version_arguments, context, limits)
619            .map_err(|source| ToolError::Execution(Box::new(source)))?;
620        let version = match std::str::from_utf8(&evidence.stdout) {
621            Ok(version) => version.trim(),
622            Err(source) => {
623                return Err(ToolError::VersionUtf8 {
624                    source,
625                    evidence: Box::new(evidence),
626                });
627            }
628        };
629        if version != spec.version_identity {
630            return Err(ToolError::VersionMismatch {
631                evidence: Box::new(evidence),
632            });
633        }
634        Ok(Self {
635            path,
636            identity,
637            executable_bytes: spec.executable_bytes,
638            version_identity: spec.version_identity.to_owned(),
639        })
640    }
641
642    /// Canonical absolute path selected during admission.
643    #[must_use]
644    pub fn path(&self) -> &Path {
645        &self.path
646    }
647
648    /// Retained raw executable identity.
649    ///
650    /// With [`Self::admit_version`], this is an observed identity, not proof of
651    /// a published binary pin or trusted installation provenance.
652    #[must_use]
653    pub const fn identity(&self) -> ArtifactIdentity {
654        self.identity
655    }
656
657    /// Successfully observed, trimmed version identity.
658    #[must_use]
659    pub fn version_identity(&self) -> &str {
660        &self.version_identity
661    }
662
663    /// Run once with a cleared, explicitly supplied environment and null stdin.
664    ///
665    /// Stdout/stderr are drained fairly through nonblocking pipes without reader
666    /// threads. On overflow/deadline the direct child is killed and reaped; pipe
667    /// handles are closed without waiting for descendants to close their copies.
668    /// Descendant processes remain caller-owned. This must not be interpreted
669    /// as a rollback or safe automatic retry of a command with external effects.
670    ///
671    /// # Errors
672    /// Returns invalid-input or identity failures before execution, or an
673    /// execution failure retaining bounded prefixes and cleanup outcomes.
674    pub fn run(
675        &self,
676        arguments: &[OsString],
677        context: &ExecutionContext<'_>,
678        limits: OutputLimits,
679    ) -> Result<ExecutionEvidence, ToolError> {
680        validate_invocation(arguments, context, limits)?;
681        verify_executable(
682            &self.path,
683            self.executable_bytes,
684            Some(self.identity.sha256),
685        )?;
686        process::capture(&self.path, arguments, context, limits)
687            .map_err(|source| ToolError::Execution(Box::new(source)))
688    }
689}
690
691fn verify_executable(
692    path: &Path,
693    limit: u64,
694    expected: Option<Sha256Digest>,
695) -> Result<ArtifactIdentity, ToolError> {
696    let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
697    if let Some(expected) = expected
698        && actual.sha256 != expected
699    {
700        return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
701            expected,
702            actual,
703        }));
704    }
705    if fs::metadata(path)
706        .map_err(ToolError::Io)?
707        .permissions()
708        .mode()
709        & 0o111
710        == 0
711    {
712        return Err(ToolError::NotExecutable);
713    }
714    Ok(actual)
715}
716
717fn validate_invocation(
718    arguments: &[OsString],
719    context: &ExecutionContext<'_>,
720    limits: OutputLimits,
721) -> Result<(), ToolError> {
722    let reject = |input| ToolError::InvalidInvocation(input);
723    if !context.current_dir.is_absolute() {
724        return Err(reject(InvalidInvocation::WorkingDirectory));
725    }
726    validate_limits(limits)?;
727    for (index, argument) in arguments.iter().enumerate() {
728        if argument.as_bytes().contains(&0) {
729            return Err(reject(InvalidInvocation::Argument { index }));
730        }
731    }
732    for (index, (key, value)) in context.environment.iter().enumerate() {
733        if key.is_empty()
734            || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
735            || context.environment[..index]
736                .iter()
737                .any(|(earlier, _)| earlier == key)
738        {
739            return Err(reject(InvalidInvocation::EnvironmentName { index }));
740        }
741        if value.as_bytes().contains(&0) {
742            return Err(reject(InvalidInvocation::EnvironmentValue { index }));
743        }
744    }
745    Ok(())
746}
747
748fn validate_limits(limits: impl Into<CommunicationLimits>) -> Result<(), ToolError> {
749    if limits.into().timeout.is_some_and(|timeout| {
750        timeout.is_zero() || std::time::Instant::now().checked_add(timeout).is_none()
751    }) {
752        return Err(ToolError::InvalidInvocation(InvalidInvocation::Deadline));
753    }
754    Ok(())
755}