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/// Why an invocation failed, independently of its retained output.
132#[derive(Debug)]
133pub enum ExecutionFailure {
134    /// The direct child completed unsuccessfully; its status is in evidence.
135    ExitStatus,
136    /// Child exit or pipe EOF was not observed before the caller's deadline.
137    TimedOut,
138    /// A stream emitted more bytes than allowed.
139    OutputLimit {
140        /// Stream whose bound was exceeded.
141        stream: OutputStream,
142    },
143    /// A process or pipe operation failed.
144    Io {
145        /// Operation category; contains no command or credential values.
146        operation: &'static str,
147        /// Underlying typed failure.
148        source: io::Error,
149    },
150    /// Retained output storage could not be allocated.
151    Allocation {
152        /// Stream being captured.
153        stream: OutputStream,
154        /// Underlying allocation failure.
155        source: std::collections::TryReserveError,
156    },
157}
158
159/// A failed invocation with bounded output and direct-child cleanup evidence.
160#[derive(Debug)]
161pub struct ExecutionError {
162    /// Original failure; never replaced by a cleanup failure.
163    pub failure: ExecutionFailure,
164    /// Observed status and bounded stdout/stderr prefixes.
165    pub evidence: ExecutionEvidence,
166    /// Failure to terminate the direct child, if termination was needed.
167    pub kill_error: Option<io::Error>,
168    /// Failure to reap the direct child, if reaping was needed.
169    pub wait_error: Option<io::Error>,
170}
171
172impl fmt::Display for ExecutionError {
173    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174        match &self.failure {
175            ExecutionFailure::ExitStatus => {
176                write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
177            }
178            ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
179            ExecutionFailure::OutputLimit { stream } => {
180                write!(f, "tool {stream:?} exceeded its byte limit")
181            }
182            ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
183            ExecutionFailure::Allocation { stream, .. } => {
184                write!(f, "tool {stream:?} allocation failed")
185            }
186        }
187    }
188}
189impl std::error::Error for ExecutionError {
190    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191        match &self.failure {
192            ExecutionFailure::Io { source, .. } => Some(source),
193            ExecutionFailure::Allocation { source, .. } => Some(source),
194            _ => None,
195        }
196    }
197}
198
199/// Executable admission or execution failed.
200#[derive(Debug)]
201pub enum ToolError {
202    /// Invalid context, authority or arguments; no child was spawned.
203    InvalidInvocation(InvalidInvocation),
204    /// Filesystem path resolution or metadata failed before execution.
205    Io(io::Error),
206    /// The selected file has no Unix executable permission bits.
207    NotExecutable,
208    /// Executable identity could not be read or did not match authority.
209    Artifact(ArtifactError),
210    /// One invocation failed; includes bounded evidence.
211    Execution(Box<ExecutionError>),
212    /// Successful version stdout was not valid UTF-8.
213    VersionUtf8 {
214        /// UTF-8 validation failure.
215        source: std::str::Utf8Error,
216        /// Raw bounded version output.
217        evidence: Box<ExecutionEvidence>,
218    },
219    /// Successful trimmed version stdout did not match the selected identity.
220    VersionMismatch {
221        /// Raw bounded version output; never formatted into the error.
222        evidence: Box<ExecutionEvidence>,
223    },
224}
225
226impl fmt::Display for ToolError {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        match self {
229            Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
230            Self::Io(_) => f.write_str("tool filesystem inspection failed"),
231            Self::NotExecutable => f.write_str("tool file is not executable"),
232            Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
233            Self::Execution(source) => source.fmt(f),
234            Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
235            Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
236        }
237    }
238}
239impl std::error::Error for ToolError {
240    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
241        match self {
242            Self::Io(source) => Some(source),
243            Self::Artifact(source) => Some(source),
244            Self::Execution(source) => Some(source.as_ref()),
245            Self::VersionUtf8 { source, .. } => Some(source),
246            _ => None,
247        }
248    }
249}
250
251/// An executable whose exact bytes and version were admitted by a consumer.
252///
253/// Every execution rechecks digest/permission before spawning. Consumers must
254/// exclude concurrent writers to the executable and its parent directories:
255/// filesystem checks and `exec` are separate operations. This is not a file
256/// capability or verification of dynamic libraries, interpreters, or descendants.
257pub struct AdmittedTool {
258    path: PathBuf,
259    identity: ArtifactIdentity,
260    executable_bytes: u64,
261    version_identity: String,
262}
263
264impl AdmittedTool {
265    /// Admit exact executable bytes before invoking the selected version command.
266    ///
267    /// # Errors
268    /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
269    /// failures, non-UTF-8 stdout, and a different successful version identity.
270    pub fn admit(
271        spec: &ToolSpec<'_>,
272        context: &ExecutionContext<'_>,
273        limits: OutputLimits,
274    ) -> Result<Self, ToolError> {
275        if !spec.executable.is_absolute() {
276            return Err(ToolError::InvalidInvocation(
277                InvalidInvocation::ExecutablePath,
278            ));
279        }
280        if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
281        {
282            return Err(ToolError::InvalidInvocation(
283                InvalidInvocation::VersionIdentity,
284            ));
285        }
286        validate_invocation(spec.version_arguments, context, limits)?;
287        let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
288        let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
289        let evidence = process::capture(&path, spec.version_arguments, context, limits)
290            .map_err(|source| ToolError::Execution(Box::new(source)))?;
291        let version = match std::str::from_utf8(&evidence.stdout) {
292            Ok(version) => version.trim(),
293            Err(source) => {
294                return Err(ToolError::VersionUtf8 {
295                    source,
296                    evidence: Box::new(evidence),
297                });
298            }
299        };
300        if version != spec.version_identity {
301            return Err(ToolError::VersionMismatch {
302                evidence: Box::new(evidence),
303            });
304        }
305        Ok(Self {
306            path,
307            identity,
308            executable_bytes: spec.executable_bytes,
309            version_identity: spec.version_identity.to_owned(),
310        })
311    }
312
313    /// Canonical absolute path selected during admission.
314    #[must_use]
315    pub fn path(&self) -> &Path {
316        &self.path
317    }
318
319    /// Admitted raw executable identity.
320    #[must_use]
321    pub const fn identity(&self) -> ArtifactIdentity {
322        self.identity
323    }
324
325    /// Successfully observed, trimmed version identity.
326    #[must_use]
327    pub fn version_identity(&self) -> &str {
328        &self.version_identity
329    }
330
331    /// Run once with a cleared, explicitly supplied environment and null stdin.
332    ///
333    /// Stdout/stderr are drained fairly through nonblocking pipes without reader
334    /// threads. On overflow/deadline the direct child is killed and reaped; pipe
335    /// handles are closed without waiting for descendants to close their copies.
336    /// Descendant processes remain caller-owned. This must not be interpreted
337    /// as a rollback or safe automatic retry of a command with external effects.
338    ///
339    /// # Errors
340    /// Returns invalid-input or identity failures before execution, or an
341    /// execution failure retaining bounded prefixes and cleanup outcomes.
342    pub fn run(
343        &self,
344        arguments: &[OsString],
345        context: &ExecutionContext<'_>,
346        limits: OutputLimits,
347    ) -> Result<ExecutionEvidence, ToolError> {
348        validate_invocation(arguments, context, limits)?;
349        verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
350        process::capture(&self.path, arguments, context, limits)
351            .map_err(|source| ToolError::Execution(Box::new(source)))
352    }
353}
354
355fn verify_executable(
356    path: &Path,
357    limit: u64,
358    expected: Sha256Digest,
359) -> Result<ArtifactIdentity, ToolError> {
360    let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
361    if actual.sha256 != expected {
362        return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
363            expected,
364            actual,
365        }));
366    }
367    if fs::metadata(path)
368        .map_err(ToolError::Io)?
369        .permissions()
370        .mode()
371        & 0o111
372        == 0
373    {
374        return Err(ToolError::NotExecutable);
375    }
376    Ok(actual)
377}
378
379fn validate_invocation(
380    arguments: &[OsString],
381    context: &ExecutionContext<'_>,
382    limits: OutputLimits,
383) -> Result<(), ToolError> {
384    let reject = |input| ToolError::InvalidInvocation(input);
385    if !context.current_dir.is_absolute() {
386        return Err(reject(InvalidInvocation::WorkingDirectory));
387    }
388    if limits.timeout.is_zero()
389        || std::time::Instant::now()
390            .checked_add(limits.timeout)
391            .is_none()
392    {
393        return Err(reject(InvalidInvocation::Deadline));
394    }
395    for (index, argument) in arguments.iter().enumerate() {
396        if argument.as_bytes().contains(&0) {
397            return Err(reject(InvalidInvocation::Argument { index }));
398        }
399    }
400    for (index, (key, value)) in context.environment.iter().enumerate() {
401        if key.is_empty()
402            || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
403            || context.environment[..index]
404                .iter()
405                .any(|(earlier, _)| earlier == key)
406        {
407            return Err(reject(InvalidInvocation::EnvironmentName { index }));
408        }
409        if value.as_bytes().contains(&0) {
410            return Err(reject(InvalidInvocation::EnvironmentValue { index }));
411        }
412    }
413    Ok(())
414}