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