Skip to main content

ic_host_process/tool/
mod.rs

1//! Digest/version admission and bounded execution of explicit Unix host tools.
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::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/// An invalid invocation was rejected before a child was spawned.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum InvalidInvocation {
67    /// Tool paths must be absolute.
68    ExecutablePath,
69    /// Working directories must be absolute.
70    WorkingDirectory,
71    /// Deadlines must be positive and representable by the host clock.
72    Deadline,
73    /// Version identity must be nonempty and already trimmed.
74    VersionIdentity,
75    /// An argument contains a NUL byte.
76    Argument {
77        /// Argument position.
78        index: usize,
79    },
80    /// An environment name is empty, contains `=`/NUL, or duplicates an earlier name.
81    EnvironmentName {
82        /// Environment entry position.
83        index: usize,
84    },
85    /// An environment value contains a NUL byte.
86    EnvironmentValue {
87        /// Environment entry position.
88        index: usize,
89    },
90}
91
92/// A captured output stream.
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub enum OutputStream {
95    /// Standard output.
96    Stdout,
97    /// Standard error.
98    Stderr,
99}
100
101/// Bounded evidence from one invocation, including interrupted invocations.
102///
103/// Bytes are available explicitly to the caller; formatting prints only lengths
104/// and status. Error messages never include command arguments or environment.
105#[derive(Default)]
106pub struct ExecutionEvidence {
107    /// Observed direct-child status, if reaped. A killed process's status does
108    /// not prove that any external effect did not happen.
109    pub status: Option<ExitStatus>,
110    /// Retained stdout prefix, bounded by the selected limit.
111    pub stdout: Vec<u8>,
112    /// Retained stderr prefix, bounded by the selected limit.
113    pub stderr: Vec<u8>,
114    /// More stdout bytes were observed than could be retained.
115    pub stdout_truncated: bool,
116    /// More stderr bytes were observed than could be retained.
117    pub stderr_truncated: bool,
118}
119
120impl fmt::Debug for ExecutionEvidence {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.debug_struct("ExecutionEvidence")
123            .field("status", &self.status)
124            .field("stdout_bytes", &self.stdout.len())
125            .field("stderr_bytes", &self.stderr.len())
126            .field("stdout_truncated", &self.stdout_truncated)
127            .field("stderr_truncated", &self.stderr_truncated)
128            .finish()
129    }
130}
131
132/// Process or pipe operation that produced an IO failure.
133///
134/// These categories contain no arguments, environment or captured output.
135#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum ExecutionOperation {
137    /// Create the child process in its selected working directory.
138    Spawn,
139    /// Obtain the child's stdout pipe.
140    StdoutPipe,
141    /// Obtain the child's stderr pipe.
142    StderrPipe,
143    /// Read a capture pipe's current descriptor flags.
144    ReadPipeFlags,
145    /// Enable nonblocking reads on a capture pipe.
146    SetPipeFlags,
147    /// Read bytes from a capture pipe.
148    ReadOutput,
149    /// Observe the direct child's exit status.
150    Wait,
151}
152
153impl fmt::Display for ExecutionOperation {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        f.write_str(match self {
156            Self::Spawn => "spawn",
157            Self::StdoutPipe => "stdout pipe",
158            Self::StderrPipe => "stderr pipe",
159            Self::ReadPipeFlags => "read pipe flags",
160            Self::SetPipeFlags => "set pipe flags",
161            Self::ReadOutput => "read output",
162            Self::Wait => "wait",
163        })
164    }
165}
166
167/// Why an invocation failed, independently of its retained output.
168#[derive(Debug)]
169pub enum ExecutionFailure {
170    /// The direct child completed unsuccessfully; its status is in evidence.
171    ExitStatus,
172    /// Child exit or pipe EOF was not observed before the caller's deadline.
173    TimedOut,
174    /// A stream emitted more bytes than allowed.
175    OutputLimit {
176        /// Stream whose bound was exceeded.
177        stream: OutputStream,
178    },
179    /// A process or pipe operation failed.
180    Io {
181        /// Operation category; contains no command or credential values.
182        operation: ExecutionOperation,
183        /// Underlying typed failure.
184        source: io::Error,
185    },
186    /// Retained output storage could not be allocated.
187    Allocation {
188        /// Stream being captured.
189        stream: OutputStream,
190        /// Underlying allocation failure.
191        source: std::collections::TryReserveError,
192    },
193}
194
195/// A failed invocation with bounded output and direct-child cleanup evidence.
196#[derive(Debug)]
197pub struct ExecutionError {
198    /// Original failure; never replaced by a cleanup failure.
199    pub failure: ExecutionFailure,
200    /// Observed status and bounded stdout/stderr prefixes.
201    pub evidence: ExecutionEvidence,
202    /// Failure to terminate the direct child, if termination was needed.
203    pub kill_error: Option<io::Error>,
204    /// Failure to reap the direct child, if reaping was needed.
205    pub wait_error: Option<io::Error>,
206}
207
208impl fmt::Display for ExecutionError {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        match &self.failure {
211            ExecutionFailure::ExitStatus => {
212                write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
213            }
214            ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
215            ExecutionFailure::OutputLimit { stream } => {
216                write!(f, "tool {stream:?} exceeded its byte limit")
217            }
218            ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
219            ExecutionFailure::Allocation { stream, .. } => {
220                write!(f, "tool {stream:?} allocation failed")
221            }
222        }
223    }
224}
225impl std::error::Error for ExecutionError {
226    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
227        match &self.failure {
228            ExecutionFailure::Io { source, .. } => Some(source),
229            ExecutionFailure::Allocation { source, .. } => Some(source),
230            _ => None,
231        }
232    }
233}
234
235/// Executable admission or execution failed.
236#[derive(Debug)]
237pub enum ToolError {
238    /// Invalid context, authority or arguments; no child was spawned.
239    InvalidInvocation(InvalidInvocation),
240    /// Filesystem path resolution or metadata failed before execution.
241    Io(io::Error),
242    /// The selected file has no Unix executable permission bits.
243    NotExecutable,
244    /// Executable identity could not be read or did not match authority.
245    Artifact(ArtifactError),
246    /// One invocation failed; includes bounded evidence.
247    Execution(Box<ExecutionError>),
248    /// Successful version stdout was not valid UTF-8.
249    VersionUtf8 {
250        /// UTF-8 validation failure.
251        source: std::str::Utf8Error,
252        /// Raw bounded version output.
253        evidence: Box<ExecutionEvidence>,
254    },
255    /// Successful trimmed version stdout did not match the selected identity.
256    VersionMismatch {
257        /// Raw bounded version output; never formatted into the error.
258        evidence: Box<ExecutionEvidence>,
259    },
260}
261
262impl fmt::Display for ToolError {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        match self {
265            Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
266            Self::Io(_) => f.write_str("tool filesystem inspection failed"),
267            Self::NotExecutable => f.write_str("tool file is not executable"),
268            Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
269            Self::Execution(source) => source.fmt(f),
270            Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
271            Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
272        }
273    }
274}
275impl std::error::Error for ToolError {
276    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
277        match self {
278            Self::Io(source) => Some(source),
279            Self::Artifact(source) => Some(source),
280            Self::Execution(source) => Some(source.as_ref()),
281            Self::VersionUtf8 { source, .. } => Some(source),
282            _ => None,
283        }
284    }
285}
286
287/// An executable whose exact bytes and version were admitted by a consumer.
288///
289/// Every execution rechecks digest/permission before spawning. Consumers must
290/// exclude concurrent writers to the executable and its parent directories:
291/// filesystem checks and `exec` are separate operations. This is not a file
292/// capability or verification of dynamic libraries, interpreters, or descendants.
293pub struct AdmittedTool {
294    path: PathBuf,
295    identity: ArtifactIdentity,
296    executable_bytes: u64,
297    version_identity: String,
298}
299
300impl AdmittedTool {
301    /// Admit exact executable bytes before invoking the selected version command.
302    ///
303    /// # Errors
304    /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
305    /// failures, non-UTF-8 stdout, and a different successful version identity.
306    pub fn admit(
307        spec: &ToolSpec<'_>,
308        context: &ExecutionContext<'_>,
309        limits: OutputLimits,
310    ) -> Result<Self, ToolError> {
311        if !spec.executable.is_absolute() {
312            return Err(ToolError::InvalidInvocation(
313                InvalidInvocation::ExecutablePath,
314            ));
315        }
316        if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
317        {
318            return Err(ToolError::InvalidInvocation(
319                InvalidInvocation::VersionIdentity,
320            ));
321        }
322        validate_invocation(spec.version_arguments, context, limits)?;
323        let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
324        let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
325        let evidence = process::capture(&path, spec.version_arguments, context, limits)
326            .map_err(|source| ToolError::Execution(Box::new(source)))?;
327        let version = match std::str::from_utf8(&evidence.stdout) {
328            Ok(version) => version.trim(),
329            Err(source) => {
330                return Err(ToolError::VersionUtf8 {
331                    source,
332                    evidence: Box::new(evidence),
333                });
334            }
335        };
336        if version != spec.version_identity {
337            return Err(ToolError::VersionMismatch {
338                evidence: Box::new(evidence),
339            });
340        }
341        Ok(Self {
342            path,
343            identity,
344            executable_bytes: spec.executable_bytes,
345            version_identity: spec.version_identity.to_owned(),
346        })
347    }
348
349    /// Canonical absolute path selected during admission.
350    #[must_use]
351    pub fn path(&self) -> &Path {
352        &self.path
353    }
354
355    /// Admitted raw executable identity.
356    #[must_use]
357    pub const fn identity(&self) -> ArtifactIdentity {
358        self.identity
359    }
360
361    /// Successfully observed, trimmed version identity.
362    #[must_use]
363    pub fn version_identity(&self) -> &str {
364        &self.version_identity
365    }
366
367    /// Run once with a cleared, explicitly supplied environment and null stdin.
368    ///
369    /// Stdout/stderr are drained fairly through nonblocking pipes without reader
370    /// threads. On overflow/deadline the direct child is killed and reaped; pipe
371    /// handles are closed without waiting for descendants to close their copies.
372    /// Descendant processes remain caller-owned. This must not be interpreted
373    /// as a rollback or safe automatic retry of a command with external effects.
374    ///
375    /// # Errors
376    /// Returns invalid-input or identity failures before execution, or an
377    /// execution failure retaining bounded prefixes and cleanup outcomes.
378    pub fn run(
379        &self,
380        arguments: &[OsString],
381        context: &ExecutionContext<'_>,
382        limits: OutputLimits,
383    ) -> Result<ExecutionEvidence, ToolError> {
384        validate_invocation(arguments, context, limits)?;
385        verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
386        process::capture(&self.path, arguments, context, limits)
387            .map_err(|source| ToolError::Execution(Box::new(source)))
388    }
389}
390
391fn verify_executable(
392    path: &Path,
393    limit: u64,
394    expected: Sha256Digest,
395) -> Result<ArtifactIdentity, ToolError> {
396    let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
397    if actual.sha256 != expected {
398        return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
399            expected,
400            actual,
401        }));
402    }
403    if fs::metadata(path)
404        .map_err(ToolError::Io)?
405        .permissions()
406        .mode()
407        & 0o111
408        == 0
409    {
410        return Err(ToolError::NotExecutable);
411    }
412    Ok(actual)
413}
414
415fn validate_invocation(
416    arguments: &[OsString],
417    context: &ExecutionContext<'_>,
418    limits: OutputLimits,
419) -> Result<(), ToolError> {
420    let reject = |input| ToolError::InvalidInvocation(input);
421    if !context.current_dir.is_absolute() {
422        return Err(reject(InvalidInvocation::WorkingDirectory));
423    }
424    if limits.timeout.is_zero()
425        || std::time::Instant::now()
426            .checked_add(limits.timeout)
427            .is_none()
428    {
429        return Err(reject(InvalidInvocation::Deadline));
430    }
431    for (index, argument) in arguments.iter().enumerate() {
432        if argument.as_bytes().contains(&0) {
433            return Err(reject(InvalidInvocation::Argument { index }));
434        }
435    }
436    for (index, (key, value)) in context.environment.iter().enumerate() {
437        if key.is_empty()
438            || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
439            || context.environment[..index]
440                .iter()
441                .any(|(earlier, _)| earlier == key)
442        {
443            return Err(reject(InvalidInvocation::EnvironmentName { index }));
444        }
445        if value.as_bytes().contains(&0) {
446            return Err(reject(InvalidInvocation::EnvironmentValue { index }));
447        }
448    }
449    Ok(())
450}