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/// 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 ToolError {
263 /// Borrow the original bounded capture, including successful version output.
264 ///
265 /// Validation and filesystem/admission failures have no capture and return
266 /// `None`. A failed spawn retains its existing empty execution evidence.
267 /// No output is copied, decoded, logged or formatted by this accessor.
268 #[must_use]
269 pub fn evidence(&self) -> Option<&ExecutionEvidence> {
270 match self {
271 Self::Execution(error) => Some(&error.evidence),
272 Self::VersionUtf8 { evidence, .. } | Self::VersionMismatch { evidence } => {
273 Some(evidence)
274 }
275 Self::InvalidInvocation(_) | Self::Io(_) | Self::NotExecutable | Self::Artifact(_) => {
276 None
277 }
278 }
279 }
280
281 /// Borrow the original execution failure and its kill/reap outcomes, if any.
282 ///
283 /// A successful version capture rejected by admission has evidence but no
284 /// execution failure. Formatting, redaction and recovery remain caller-owned.
285 #[must_use]
286 pub fn execution_error(&self) -> Option<&ExecutionError> {
287 match self {
288 Self::Execution(error) => Some(error),
289 _ => None,
290 }
291 }
292}
293
294/// Capture one caller-configured command without performing executable admission.
295///
296/// The caller owns the program, arguments, working directory, environment and
297/// platform setup. Their [`Command`] settings are used unchanged except that
298/// stdin is set to null and stdout/stderr to pipes. Ambient environment or PATH
299/// search remains enabled if the caller's command enables it. No digest/version
300/// check, credential selection, command reconstruction or retry is performed.
301/// Use [`AdmittedTool`] when exact executable-byte/version admission is required.
302///
303/// Shares the admitted-tool execution engine: stdout/stderr are drained fairly
304/// with bounded storage, and the deadline starts immediately before spawning
305/// and extends through pipe EOF. On failure, pipes are closed and the direct
306/// child is terminated/reaped, retaining the original failure and cleanup
307/// evidence. Descendants, platform setup hooks and inherited descriptor lifetimes
308/// remain caller-owned. Spawning, setup hooks and kill/reap are synchronous and
309/// may exceed the deadline. This does not supervise a process group, roll back
310/// an external effect or make an uncertain command safe to repeat.
311///
312/// # Errors
313/// Rejects an invalid deadline before touching or spawning the command. Spawn,
314/// capture, nonzero exit, overflow and deadline failures retain bounded evidence.
315pub fn capture_command(
316 command: &mut Command,
317 limits: OutputLimits,
318) -> Result<ExecutionEvidence, ToolError> {
319 validate_limits(limits)?;
320 process::capture_command(command, limits)
321 .map_err(|source| ToolError::Execution(Box::new(source)))
322}
323
324impl fmt::Display for ToolError {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 match self {
327 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
328 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
329 Self::NotExecutable => f.write_str("tool file is not executable"),
330 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
331 Self::Execution(source) => source.fmt(f),
332 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
333 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
334 }
335 }
336}
337impl std::error::Error for ToolError {
338 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
339 match self {
340 Self::Io(source) => Some(source),
341 Self::Artifact(source) => Some(source),
342 Self::Execution(source) => Some(source.as_ref()),
343 Self::VersionUtf8 { source, .. } => Some(source),
344 _ => None,
345 }
346 }
347}
348
349/// An executable whose exact bytes and version were admitted by a consumer.
350///
351/// Every execution rechecks digest/permission before spawning. Consumers must
352/// exclude concurrent writers to the executable and its parent directories:
353/// filesystem checks and `exec` are separate operations. This is not a file
354/// capability or verification of dynamic libraries, interpreters, or descendants.
355pub struct AdmittedTool {
356 path: PathBuf,
357 identity: ArtifactIdentity,
358 executable_bytes: u64,
359 version_identity: String,
360}
361
362impl AdmittedTool {
363 /// Admit exact executable bytes before invoking the selected version command.
364 ///
365 /// # Errors
366 /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
367 /// failures, non-UTF-8 stdout, and a different successful version identity.
368 pub fn admit(
369 spec: &ToolSpec<'_>,
370 context: &ExecutionContext<'_>,
371 limits: OutputLimits,
372 ) -> Result<Self, ToolError> {
373 if !spec.executable.is_absolute() {
374 return Err(ToolError::InvalidInvocation(
375 InvalidInvocation::ExecutablePath,
376 ));
377 }
378 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
379 {
380 return Err(ToolError::InvalidInvocation(
381 InvalidInvocation::VersionIdentity,
382 ));
383 }
384 validate_invocation(spec.version_arguments, context, limits)?;
385 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
386 let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
387 let evidence = process::capture(&path, spec.version_arguments, context, limits)
388 .map_err(|source| ToolError::Execution(Box::new(source)))?;
389 let version = match std::str::from_utf8(&evidence.stdout) {
390 Ok(version) => version.trim(),
391 Err(source) => {
392 return Err(ToolError::VersionUtf8 {
393 source,
394 evidence: Box::new(evidence),
395 });
396 }
397 };
398 if version != spec.version_identity {
399 return Err(ToolError::VersionMismatch {
400 evidence: Box::new(evidence),
401 });
402 }
403 Ok(Self {
404 path,
405 identity,
406 executable_bytes: spec.executable_bytes,
407 version_identity: spec.version_identity.to_owned(),
408 })
409 }
410
411 /// Canonical absolute path selected during admission.
412 #[must_use]
413 pub fn path(&self) -> &Path {
414 &self.path
415 }
416
417 /// Admitted raw executable identity.
418 #[must_use]
419 pub const fn identity(&self) -> ArtifactIdentity {
420 self.identity
421 }
422
423 /// Successfully observed, trimmed version identity.
424 #[must_use]
425 pub fn version_identity(&self) -> &str {
426 &self.version_identity
427 }
428
429 /// Run once with a cleared, explicitly supplied environment and null stdin.
430 ///
431 /// Stdout/stderr are drained fairly through nonblocking pipes without reader
432 /// threads. On overflow/deadline the direct child is killed and reaped; pipe
433 /// handles are closed without waiting for descendants to close their copies.
434 /// Descendant processes remain caller-owned. This must not be interpreted
435 /// as a rollback or safe automatic retry of a command with external effects.
436 ///
437 /// # Errors
438 /// Returns invalid-input or identity failures before execution, or an
439 /// execution failure retaining bounded prefixes and cleanup outcomes.
440 pub fn run(
441 &self,
442 arguments: &[OsString],
443 context: &ExecutionContext<'_>,
444 limits: OutputLimits,
445 ) -> Result<ExecutionEvidence, ToolError> {
446 validate_invocation(arguments, context, limits)?;
447 verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
448 process::capture(&self.path, arguments, context, limits)
449 .map_err(|source| ToolError::Execution(Box::new(source)))
450 }
451}
452
453fn verify_executable(
454 path: &Path,
455 limit: u64,
456 expected: Sha256Digest,
457) -> Result<ArtifactIdentity, ToolError> {
458 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
459 if actual.sha256 != expected {
460 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
461 expected,
462 actual,
463 }));
464 }
465 if fs::metadata(path)
466 .map_err(ToolError::Io)?
467 .permissions()
468 .mode()
469 & 0o111
470 == 0
471 {
472 return Err(ToolError::NotExecutable);
473 }
474 Ok(actual)
475}
476
477fn validate_invocation(
478 arguments: &[OsString],
479 context: &ExecutionContext<'_>,
480 limits: OutputLimits,
481) -> Result<(), ToolError> {
482 let reject = |input| ToolError::InvalidInvocation(input);
483 if !context.current_dir.is_absolute() {
484 return Err(reject(InvalidInvocation::WorkingDirectory));
485 }
486 validate_limits(limits)?;
487 for (index, argument) in arguments.iter().enumerate() {
488 if argument.as_bytes().contains(&0) {
489 return Err(reject(InvalidInvocation::Argument { index }));
490 }
491 }
492 for (index, (key, value)) in context.environment.iter().enumerate() {
493 if key.is_empty()
494 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
495 || context.environment[..index]
496 .iter()
497 .any(|(earlier, _)| earlier == key)
498 {
499 return Err(reject(InvalidInvocation::EnvironmentName { index }));
500 }
501 if value.as_bytes().contains(&0) {
502 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
503 }
504 }
505 Ok(())
506}
507
508fn validate_limits(limits: OutputLimits) -> Result<(), ToolError> {
509 if limits.timeout.is_zero()
510 || std::time::Instant::now()
511 .checked_add(limits.timeout)
512 .is_none()
513 {
514 return Err(ToolError::InvalidInvocation(InvalidInvocation::Deadline));
515 }
516 Ok(())
517}