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