Skip to main content

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