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