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