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/// Exact version authority for a caller-trusted installed executable.
65///
66/// Unlike [`ToolSpec`], this supplies no trusted executable digest. Admission
67/// records the installed bytes for later drift checks; a matching version is
68/// not authentication of those bytes. Consumers own installation provenance.
69pub struct VersionSpec<'a> {
70 /// Absolute path in a caller-controlled filesystem; no PATH search occurs.
71 pub executable: &'a Path,
72 /// Maximum executable bytes read during admission and every later run.
73 pub executable_bytes: u64,
74 /// Exact argument vector used to observe version identity.
75 pub version_arguments: &'a [OsString],
76 /// Required UTF-8 stdout after Unicode whitespace is trimmed at both ends.
77 pub version_identity: &'a str,
78}
79
80/// An invalid invocation was rejected before a child was spawned.
81#[derive(Clone, Copy, Debug, Eq, PartialEq)]
82pub enum InvalidInvocation {
83 /// Tool paths must be absolute.
84 ExecutablePath,
85 /// Working directories must be absolute.
86 WorkingDirectory,
87 /// Deadlines must be positive and representable by the host clock.
88 Deadline,
89 /// Version identity must be nonempty and already trimmed.
90 VersionIdentity,
91 /// An argument contains a NUL byte.
92 Argument {
93 /// Argument position.
94 index: usize,
95 },
96 /// An environment name is empty, contains `=`/NUL, or duplicates an earlier name.
97 EnvironmentName {
98 /// Environment entry position.
99 index: usize,
100 },
101 /// An environment value contains a NUL byte.
102 EnvironmentValue {
103 /// Environment entry position.
104 index: usize,
105 },
106}
107
108/// A captured output stream.
109#[derive(Clone, Copy, Debug, Eq, PartialEq)]
110pub enum OutputStream {
111 /// Standard output.
112 Stdout,
113 /// Standard error.
114 Stderr,
115}
116
117/// Bounded evidence from one invocation, including interrupted invocations.
118///
119/// Bytes are available explicitly to the caller; formatting prints only lengths
120/// and status. Error messages never include command arguments or environment.
121#[derive(Default)]
122pub struct ExecutionEvidence {
123 /// Observed direct-child status, if reaped. A killed process's status does
124 /// not prove that any external effect did not happen.
125 pub status: Option<ExitStatus>,
126 /// Retained stdout prefix, bounded by the selected limit.
127 pub stdout: Vec<u8>,
128 /// Retained stderr prefix, bounded by the selected limit.
129 pub stderr: Vec<u8>,
130 /// More stdout bytes were observed than could be retained.
131 pub stdout_truncated: bool,
132 /// More stderr bytes were observed than could be retained.
133 pub stderr_truncated: bool,
134}
135
136impl fmt::Debug for ExecutionEvidence {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.debug_struct("ExecutionEvidence")
139 .field("status", &self.status)
140 .field("stdout_bytes", &self.stdout.len())
141 .field("stderr_bytes", &self.stderr.len())
142 .field("stdout_truncated", &self.stdout_truncated)
143 .field("stderr_truncated", &self.stderr_truncated)
144 .finish()
145 }
146}
147
148/// Process or pipe operation that produced an IO failure.
149///
150/// These categories contain no arguments, environment or captured output.
151#[derive(Clone, Copy, Debug, Eq, PartialEq)]
152pub enum ExecutionOperation {
153 /// Create the child process in its selected working directory.
154 Spawn,
155 /// Obtain the child's stdout pipe.
156 StdoutPipe,
157 /// Obtain the child's stderr pipe.
158 StderrPipe,
159 /// Read a capture pipe's current descriptor flags.
160 ReadPipeFlags,
161 /// Enable nonblocking reads on a capture pipe.
162 SetPipeFlags,
163 /// Read bytes from a capture pipe.
164 ReadOutput,
165 /// Observe the direct child's exit status.
166 Wait,
167}
168
169impl fmt::Display for ExecutionOperation {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 f.write_str(match self {
172 Self::Spawn => "spawn",
173 Self::StdoutPipe => "stdout pipe",
174 Self::StderrPipe => "stderr pipe",
175 Self::ReadPipeFlags => "read pipe flags",
176 Self::SetPipeFlags => "set pipe flags",
177 Self::ReadOutput => "read output",
178 Self::Wait => "wait",
179 })
180 }
181}
182
183/// Why an invocation failed, independently of its retained output.
184#[derive(Debug)]
185pub enum ExecutionFailure {
186 /// The direct child completed unsuccessfully; its status is in evidence.
187 ExitStatus,
188 /// Child exit or pipe EOF was not observed before the caller's deadline.
189 TimedOut,
190 /// A stream emitted more bytes than allowed.
191 OutputLimit {
192 /// Stream whose bound was exceeded.
193 stream: OutputStream,
194 },
195 /// A process or pipe operation failed.
196 Io {
197 /// Operation category; contains no command or credential values.
198 operation: ExecutionOperation,
199 /// Underlying typed failure.
200 source: io::Error,
201 },
202 /// Retained output storage could not be allocated.
203 Allocation {
204 /// Stream being captured.
205 stream: OutputStream,
206 /// Underlying allocation failure.
207 source: std::collections::TryReserveError,
208 },
209}
210
211/// A failed invocation with bounded output and direct-child cleanup evidence.
212#[derive(Debug)]
213pub struct ExecutionError {
214 /// Original failure; never replaced by a cleanup failure.
215 pub failure: ExecutionFailure,
216 /// Observed status and bounded stdout/stderr prefixes.
217 pub evidence: ExecutionEvidence,
218 /// Failure to terminate the direct child, if termination was needed.
219 pub kill_error: Option<io::Error>,
220 /// Failure to reap the direct child, if reaping was needed.
221 pub wait_error: Option<io::Error>,
222}
223
224impl fmt::Display for ExecutionError {
225 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226 match &self.failure {
227 ExecutionFailure::ExitStatus => {
228 write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
229 }
230 ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
231 ExecutionFailure::OutputLimit { stream } => {
232 write!(f, "tool {stream:?} exceeded its byte limit")
233 }
234 ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
235 ExecutionFailure::Allocation { stream, .. } => {
236 write!(f, "tool {stream:?} allocation failed")
237 }
238 }
239 }
240}
241impl std::error::Error for ExecutionError {
242 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
243 match &self.failure {
244 ExecutionFailure::Io { source, .. } => Some(source),
245 ExecutionFailure::Allocation { source, .. } => Some(source),
246 _ => None,
247 }
248 }
249}
250
251/// Executable admission or execution failed.
252#[derive(Debug)]
253pub enum ToolError {
254 /// Invalid context, authority or arguments; no child was spawned.
255 InvalidInvocation(InvalidInvocation),
256 /// Filesystem path resolution or metadata failed before execution.
257 Io(io::Error),
258 /// The selected file has no Unix executable permission bits.
259 NotExecutable,
260 /// Executable identity could not be read or did not match authority.
261 Artifact(ArtifactError),
262 /// One invocation failed; includes bounded evidence.
263 Execution(Box<ExecutionError>),
264 /// Successful version stdout was not valid UTF-8.
265 VersionUtf8 {
266 /// UTF-8 validation failure.
267 source: std::str::Utf8Error,
268 /// Raw bounded version output.
269 evidence: Box<ExecutionEvidence>,
270 },
271 /// Successful trimmed version stdout did not match the selected identity.
272 VersionMismatch {
273 /// Raw bounded version output; never formatted into the error.
274 evidence: Box<ExecutionEvidence>,
275 },
276}
277
278impl ToolError {
279 /// Borrow the original bounded capture, including successful version output.
280 ///
281 /// Validation and filesystem/admission failures have no capture and return
282 /// `None`. A failed spawn retains its existing empty execution evidence.
283 /// No output is copied, decoded, logged or formatted by this accessor.
284 #[must_use]
285 pub fn evidence(&self) -> Option<&ExecutionEvidence> {
286 match self {
287 Self::Execution(error) => Some(&error.evidence),
288 Self::VersionUtf8 { evidence, .. } | Self::VersionMismatch { evidence } => {
289 Some(evidence)
290 }
291 Self::InvalidInvocation(_) | Self::Io(_) | Self::NotExecutable | Self::Artifact(_) => {
292 None
293 }
294 }
295 }
296
297 /// Borrow the original execution failure and its kill/reap outcomes, if any.
298 ///
299 /// A successful version capture rejected by admission has evidence but no
300 /// execution failure. Formatting, redaction and recovery remain caller-owned.
301 #[must_use]
302 pub fn execution_error(&self) -> Option<&ExecutionError> {
303 match self {
304 Self::Execution(error) => Some(error),
305 _ => None,
306 }
307 }
308}
309
310/// Capture one caller-configured command without performing executable admission.
311///
312/// The caller owns the program, arguments, working directory, environment and
313/// platform setup. Their [`Command`] settings are used unchanged except that
314/// stdin is set to null and stdout/stderr to pipes. Ambient environment or PATH
315/// search remains enabled if the caller's command enables it. No digest/version
316/// check, credential selection, command reconstruction or retry is performed.
317/// Use [`AdmittedTool`] when exact executable-byte/version admission is required.
318///
319/// Shares the admitted-tool execution engine: stdout/stderr are drained fairly
320/// with bounded storage, and the deadline starts immediately before spawning
321/// and extends through pipe EOF. On failure, pipes are closed and the direct
322/// child is terminated/reaped, retaining the original failure and cleanup
323/// evidence. Descendants, platform setup hooks and inherited descriptor lifetimes
324/// remain caller-owned. Spawning, setup hooks and kill/reap are synchronous and
325/// may exceed the deadline. This does not supervise a process group, roll back
326/// an external effect or make an uncertain command safe to repeat.
327///
328/// # Errors
329/// Rejects an invalid deadline before touching or spawning the command. Spawn,
330/// capture, nonzero exit, overflow and deadline failures retain bounded evidence.
331pub fn capture_command(
332 command: &mut Command,
333 limits: OutputLimits,
334) -> Result<ExecutionEvidence, ToolError> {
335 validate_limits(limits)?;
336 process::capture_command(command, limits)
337 .map_err(|source| ToolError::Execution(Box::new(source)))
338}
339
340impl fmt::Display for ToolError {
341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 match self {
343 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
344 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
345 Self::NotExecutable => f.write_str("tool file is not executable"),
346 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
347 Self::Execution(source) => source.fmt(f),
348 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
349 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
350 }
351 }
352}
353impl std::error::Error for ToolError {
354 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
355 match self {
356 Self::Io(source) => Some(source),
357 Self::Artifact(source) => Some(source),
358 Self::Execution(source) => Some(source.as_ref()),
359 Self::VersionUtf8 { source, .. } => Some(source),
360 _ => None,
361 }
362 }
363}
364
365/// An executable with a retained byte identity and an admitted exact version.
366///
367/// [`Self::admit`] requires a consumer-supplied digest. [`Self::admit_version`]
368/// records the installed identity without authenticating it against a pin.
369/// Every execution rechecks digest/permission before spawning. Consumers must
370/// exclude concurrent writers to the executable and its parent directories:
371/// filesystem checks and `exec` are separate operations. This is not a file
372/// capability or verification of dynamic libraries, interpreters, or descendants.
373pub struct AdmittedTool {
374 path: PathBuf,
375 identity: ArtifactIdentity,
376 executable_bytes: u64,
377 version_identity: String,
378}
379
380impl AdmittedTool {
381 /// Admit exact executable bytes before invoking the selected version command.
382 ///
383 /// # Errors
384 /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
385 /// failures, non-UTF-8 stdout, and a different successful version identity.
386 pub fn admit(
387 spec: &ToolSpec<'_>,
388 context: &ExecutionContext<'_>,
389 limits: OutputLimits,
390 ) -> Result<Self, ToolError> {
391 Self::admit_with_digest(
392 &VersionSpec {
393 executable: spec.executable,
394 executable_bytes: spec.executable_bytes,
395 version_arguments: spec.version_arguments,
396 version_identity: spec.version_identity,
397 },
398 Some(spec.sha256),
399 context,
400 limits,
401 )
402 }
403
404 /// Admit an exact version and record the caller-trusted installed bytes.
405 ///
406 /// For tools built locally, no portable published digest may exist. This
407 /// entry hashes the executable within the supplied budget before running
408 /// the version command. The resulting [`Self::identity`] is an observation,
409 /// not a trusted published pin. All later runs reject changed bytes using
410 /// the same verification and capture engine as [`Self::admit`].
411 ///
412 /// The caller must trust the installation before admission: the version
413 /// command executes those bytes. Exact version output does not establish
414 /// authenticity. No version ranges, tool installation or PATH search occur.
415 /// The caller must exclude concurrent executable/directory writers.
416 ///
417 /// # Errors
418 /// Rejects invalid inputs, non-executable or oversized files, process
419 /// failures, non-UTF-8 stdout and a different successful version identity.
420 pub fn admit_version(
421 spec: &VersionSpec<'_>,
422 context: &ExecutionContext<'_>,
423 limits: OutputLimits,
424 ) -> Result<Self, ToolError> {
425 Self::admit_with_digest(spec, None, context, limits)
426 }
427
428 fn admit_with_digest(
429 spec: &VersionSpec<'_>,
430 expected: Option<Sha256Digest>,
431 context: &ExecutionContext<'_>,
432 limits: OutputLimits,
433 ) -> Result<Self, ToolError> {
434 if !spec.executable.is_absolute() {
435 return Err(ToolError::InvalidInvocation(
436 InvalidInvocation::ExecutablePath,
437 ));
438 }
439 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
440 {
441 return Err(ToolError::InvalidInvocation(
442 InvalidInvocation::VersionIdentity,
443 ));
444 }
445 validate_invocation(spec.version_arguments, context, limits)?;
446 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
447 let identity = verify_executable(&path, spec.executable_bytes, expected)?;
448 let evidence = process::capture(&path, spec.version_arguments, context, limits)
449 .map_err(|source| ToolError::Execution(Box::new(source)))?;
450 let version = match std::str::from_utf8(&evidence.stdout) {
451 Ok(version) => version.trim(),
452 Err(source) => {
453 return Err(ToolError::VersionUtf8 {
454 source,
455 evidence: Box::new(evidence),
456 });
457 }
458 };
459 if version != spec.version_identity {
460 return Err(ToolError::VersionMismatch {
461 evidence: Box::new(evidence),
462 });
463 }
464 Ok(Self {
465 path,
466 identity,
467 executable_bytes: spec.executable_bytes,
468 version_identity: spec.version_identity.to_owned(),
469 })
470 }
471
472 /// Canonical absolute path selected during admission.
473 #[must_use]
474 pub fn path(&self) -> &Path {
475 &self.path
476 }
477
478 /// Retained raw executable identity.
479 ///
480 /// With [`Self::admit_version`], this is an observed identity, not proof of
481 /// a published binary pin or trusted installation provenance.
482 #[must_use]
483 pub const fn identity(&self) -> ArtifactIdentity {
484 self.identity
485 }
486
487 /// Successfully observed, trimmed version identity.
488 #[must_use]
489 pub fn version_identity(&self) -> &str {
490 &self.version_identity
491 }
492
493 /// Run once with a cleared, explicitly supplied environment and null stdin.
494 ///
495 /// Stdout/stderr are drained fairly through nonblocking pipes without reader
496 /// threads. On overflow/deadline the direct child is killed and reaped; pipe
497 /// handles are closed without waiting for descendants to close their copies.
498 /// Descendant processes remain caller-owned. This must not be interpreted
499 /// as a rollback or safe automatic retry of a command with external effects.
500 ///
501 /// # Errors
502 /// Returns invalid-input or identity failures before execution, or an
503 /// execution failure retaining bounded prefixes and cleanup outcomes.
504 pub fn run(
505 &self,
506 arguments: &[OsString],
507 context: &ExecutionContext<'_>,
508 limits: OutputLimits,
509 ) -> Result<ExecutionEvidence, ToolError> {
510 validate_invocation(arguments, context, limits)?;
511 verify_executable(
512 &self.path,
513 self.executable_bytes,
514 Some(self.identity.sha256),
515 )?;
516 process::capture(&self.path, arguments, context, limits)
517 .map_err(|source| ToolError::Execution(Box::new(source)))
518 }
519}
520
521fn verify_executable(
522 path: &Path,
523 limit: u64,
524 expected: Option<Sha256Digest>,
525) -> Result<ArtifactIdentity, ToolError> {
526 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
527 if let Some(expected) = expected
528 && actual.sha256 != expected
529 {
530 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
531 expected,
532 actual,
533 }));
534 }
535 if fs::metadata(path)
536 .map_err(ToolError::Io)?
537 .permissions()
538 .mode()
539 & 0o111
540 == 0
541 {
542 return Err(ToolError::NotExecutable);
543 }
544 Ok(actual)
545}
546
547fn validate_invocation(
548 arguments: &[OsString],
549 context: &ExecutionContext<'_>,
550 limits: OutputLimits,
551) -> Result<(), ToolError> {
552 let reject = |input| ToolError::InvalidInvocation(input);
553 if !context.current_dir.is_absolute() {
554 return Err(reject(InvalidInvocation::WorkingDirectory));
555 }
556 validate_limits(limits)?;
557 for (index, argument) in arguments.iter().enumerate() {
558 if argument.as_bytes().contains(&0) {
559 return Err(reject(InvalidInvocation::Argument { index }));
560 }
561 }
562 for (index, (key, value)) in context.environment.iter().enumerate() {
563 if key.is_empty()
564 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
565 || context.environment[..index]
566 .iter()
567 .any(|(earlier, _)| earlier == key)
568 {
569 return Err(reject(InvalidInvocation::EnvironmentName { index }));
570 }
571 if value.as_bytes().contains(&0) {
572 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
573 }
574 }
575 Ok(())
576}
577
578fn validate_limits(limits: OutputLimits) -> Result<(), ToolError> {
579 if limits.timeout.is_zero()
580 || std::time::Instant::now()
581 .checked_add(limits.timeout)
582 .is_none()
583 {
584 return Err(ToolError::InvalidInvocation(InvalidInvocation::Deadline));
585 }
586 Ok(())
587}