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 /// or from entry to [`communicate_child`] for an already spawned child.
34 /// Verification reads, spawning syscalls and kill/reap may take longer.
35 pub timeout: Duration,
36}
37
38/// Explicit process context. The child's inherited environment is cleared.
39///
40/// No ambient PATH, HOME or credentials are added. Consumers must include any
41/// environment needed by their tool. This type intentionally has no `Debug`
42/// implementation because its values may contain credentials.
43pub struct ExecutionContext<'a> {
44 /// Absolute current directory for the child.
45 pub current_dir: &'a Path,
46 /// Complete environment; names must be nonempty, unique, and contain no
47 /// `=` or NUL bytes. Values must contain no NUL bytes.
48 pub environment: &'a [(OsString, OsString)],
49}
50
51/// Exact executable and version authority selected by a consumer.
52pub struct ToolSpec<'a> {
53 /// Absolute path in a caller-controlled filesystem; no PATH search occurs.
54 pub executable: &'a Path,
55 /// Admitted SHA-256 of the executable bytes.
56 pub sha256: Sha256Digest,
57 /// Maximum executable bytes read during every verification.
58 pub executable_bytes: u64,
59 /// Exact argument vector used to observe version identity.
60 pub version_arguments: &'a [OsString],
61 /// Required UTF-8 stdout after Unicode whitespace is trimmed at both ends.
62 pub version_identity: &'a str,
63}
64
65/// Exact version authority for a caller-trusted installed executable.
66///
67/// Unlike [`ToolSpec`], this supplies no trusted executable digest. Admission
68/// records the installed bytes for later drift checks; a matching version is
69/// not authentication of those bytes. Consumers own installation provenance.
70pub struct VersionSpec<'a> {
71 /// Absolute path in a caller-controlled filesystem; no PATH search occurs.
72 pub executable: &'a Path,
73 /// Maximum executable bytes read during admission and every later run.
74 pub executable_bytes: u64,
75 /// Exact argument vector used to observe version identity.
76 pub version_arguments: &'a [OsString],
77 /// Required UTF-8 stdout after Unicode whitespace is trimmed at both ends.
78 pub version_identity: &'a str,
79}
80
81/// Invocation parameters rejected before dispatch or communication.
82/// An already spawned child remains owned and untouched by this validation.
83#[derive(Clone, Copy, Debug, Eq, PartialEq)]
84pub enum InvalidInvocation {
85 /// Tool paths must be absolute.
86 ExecutablePath,
87 /// Working directories must be absolute.
88 WorkingDirectory,
89 /// Deadlines must be positive and representable by the host clock.
90 Deadline,
91 /// Version identity must be nonempty and already trimmed.
92 VersionIdentity,
93 /// An argument contains a NUL byte.
94 Argument {
95 /// Argument position.
96 index: usize,
97 },
98 /// An environment name is empty, contains `=`/NUL, or duplicates an earlier name.
99 EnvironmentName {
100 /// Environment entry position.
101 index: usize,
102 },
103 /// An environment value contains a NUL byte.
104 EnvironmentValue {
105 /// Environment entry position.
106 index: usize,
107 },
108}
109
110/// A captured output stream.
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub enum OutputStream {
113 /// Standard output.
114 Stdout,
115 /// Standard error.
116 Stderr,
117}
118
119/// Bounded evidence from one invocation, including interrupted invocations.
120///
121/// Bytes are available explicitly to the caller; formatting prints only lengths
122/// and status. Error messages never include command arguments or environment.
123#[derive(Default)]
124pub struct ExecutionEvidence {
125 /// Observed direct-child status. Communication can reserve a successful
126 /// leader without reaping it. Status does not prove external effects absent.
127 pub status: Option<ExitStatus>,
128 /// Retained stdout prefix, bounded by the selected limit.
129 pub stdout: Vec<u8>,
130 /// Retained stderr prefix, bounded by the selected limit.
131 pub stderr: Vec<u8>,
132 /// More stdout bytes were observed than could be retained.
133 pub stdout_truncated: bool,
134 /// More stderr bytes were observed than could be retained.
135 pub stderr_truncated: bool,
136}
137
138impl fmt::Debug for ExecutionEvidence {
139 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140 f.debug_struct("ExecutionEvidence")
141 .field("status", &self.status)
142 .field("stdout_bytes", &self.stdout.len())
143 .field("stderr_bytes", &self.stderr.len())
144 .field("stdout_truncated", &self.stdout_truncated)
145 .field("stderr_truncated", &self.stderr_truncated)
146 .finish()
147 }
148}
149
150/// Process or pipe operation that produced an IO failure.
151///
152/// These categories contain no arguments, environment or captured output.
153#[derive(Clone, Copy, Debug, Eq, PartialEq)]
154pub enum ExecutionOperation {
155 /// Create the child process in its selected working directory.
156 Spawn,
157 /// Obtain the child's pipe for explicitly supplied input.
158 StdinPipe,
159 /// Read an IO pipe's current descriptor flags.
160 ReadPipeFlags,
161 /// Enable nonblocking IO on a pipe.
162 SetPipeFlags,
163 /// Read bytes from a capture pipe.
164 ReadOutput,
165 /// Write bytes to the child's stdin pipe.
166 WriteInput,
167 /// Observe child exit, including selected group cleanup before reaping.
168 Wait,
169}
170
171impl fmt::Display for ExecutionOperation {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 f.write_str(match self {
174 Self::Spawn => "spawn",
175 Self::StdinPipe => "stdin pipe",
176 Self::ReadPipeFlags => "read pipe flags",
177 Self::SetPipeFlags => "set pipe flags",
178 Self::ReadOutput => "read output",
179 Self::WriteInput => "write input",
180 Self::Wait => "wait",
181 })
182 }
183}
184
185/// Why an invocation failed, independently of its retained output.
186#[derive(Debug)]
187pub enum ExecutionFailure {
188 /// The direct child completed unsuccessfully; its status is in evidence.
189 ExitStatus,
190 /// Child exit or pipe EOF was not observed before the caller's deadline.
191 TimedOut,
192 /// The caller's cancellation predicate requested cleanup.
193 Cancelled,
194 /// A stream emitted more bytes than allowed.
195 OutputLimit {
196 /// Stream whose bound was exceeded.
197 stream: OutputStream,
198 },
199 /// A process or pipe operation failed.
200 Io {
201 /// Operation category; contains no command or credential values.
202 operation: ExecutionOperation,
203 /// Underlying typed failure.
204 source: io::Error,
205 },
206 /// Retained output storage could not be allocated.
207 Allocation {
208 /// Stream being captured.
209 stream: OutputStream,
210 /// Underlying allocation failure.
211 source: std::collections::TryReserveError,
212 },
213}
214
215/// A failed invocation with bounded output and separately retained cleanup errors.
216#[derive(Debug)]
217pub struct ExecutionError {
218 /// Original failure; never replaced by a cleanup failure.
219 pub failure: ExecutionFailure,
220 /// Observed status and bounded stdout/stderr prefixes.
221 pub evidence: ExecutionEvidence,
222 /// Failure signalling TERM under the caller-selected child cleanup policy.
223 pub term_error: Option<io::Error>,
224 /// Failure signalling KILL to an owned process group; absent in direct capture.
225 pub group_error: Option<io::Error>,
226 /// Failure to terminate the direct child, if termination was needed.
227 pub kill_error: Option<io::Error>,
228 /// Failure to reap the direct child, if reaping was needed.
229 pub wait_error: Option<io::Error>,
230}
231
232impl fmt::Display for ExecutionError {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 match &self.failure {
235 ExecutionFailure::ExitStatus => {
236 write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
237 }
238 ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
239 ExecutionFailure::Cancelled => f.write_str("tool communication cancelled"),
240 ExecutionFailure::OutputLimit { stream } => {
241 write!(f, "tool {stream:?} exceeded its byte limit")
242 }
243 ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
244 ExecutionFailure::Allocation { stream, .. } => {
245 write!(f, "tool {stream:?} allocation failed")
246 }
247 }
248 }
249}
250impl std::error::Error for ExecutionError {
251 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
252 match &self.failure {
253 ExecutionFailure::Io { source, .. } => Some(source),
254 ExecutionFailure::Allocation { source, .. } => Some(source),
255 _ => None,
256 }
257 }
258}
259
260/// Executable admission or execution failed.
261#[derive(Debug)]
262pub enum ToolError {
263 /// Invalid context, authority or arguments; no new child was spawned.
264 /// Communication validation leaves an existing child and its pipes untouched.
265 InvalidInvocation(InvalidInvocation),
266 /// Filesystem path resolution or metadata failed before execution.
267 Io(io::Error),
268 /// The selected file has no Unix executable permission bits.
269 NotExecutable,
270 /// Executable identity could not be read or did not match authority.
271 Artifact(ArtifactError),
272 /// One invocation failed; includes bounded evidence.
273 Execution(Box<ExecutionError>),
274 /// Successful version stdout was not valid UTF-8.
275 VersionUtf8 {
276 /// UTF-8 validation failure.
277 source: std::str::Utf8Error,
278 /// Raw bounded version output.
279 evidence: Box<ExecutionEvidence>,
280 },
281 /// Successful trimmed version stdout did not match the selected identity.
282 VersionMismatch {
283 /// Raw bounded version output; never formatted into the error.
284 evidence: Box<ExecutionEvidence>,
285 },
286}
287
288impl ToolError {
289 /// Borrow the original bounded capture, including successful version output.
290 ///
291 /// Validation and filesystem/admission failures have no capture and return
292 /// `None`. A failed spawn retains its existing empty execution evidence.
293 /// No output is copied, decoded, logged or formatted by this accessor.
294 #[must_use]
295 pub fn evidence(&self) -> Option<&ExecutionEvidence> {
296 match self {
297 Self::Execution(error) => Some(&error.evidence),
298 Self::VersionUtf8 { evidence, .. } | Self::VersionMismatch { evidence } => {
299 Some(evidence)
300 }
301 Self::InvalidInvocation(_) | Self::Io(_) | Self::NotExecutable | Self::Artifact(_) => {
302 None
303 }
304 }
305 }
306
307 /// Borrow the original execution failure and its kill/reap outcomes, if any.
308 ///
309 /// A successful version capture rejected by admission has evidence but no
310 /// execution failure. Formatting, redaction and recovery remain caller-owned.
311 #[must_use]
312 pub fn execution_error(&self) -> Option<&ExecutionError> {
313 match self {
314 Self::Execution(error) => Some(error),
315 _ => None,
316 }
317 }
318}
319
320/// Capture one caller-configured command without performing executable admission.
321///
322/// The caller owns the program, arguments, working directory, environment and
323/// platform setup. Their [`Command`] settings are used unchanged except that
324/// stdin is set to null and stdout/stderr to pipes. Ambient environment or PATH
325/// search remains enabled if the caller's command enables it. No digest/version
326/// check, credential selection, command reconstruction or retry is performed.
327/// Use [`AdmittedTool`] when exact executable-byte/version admission is required.
328///
329/// Shares the admitted-tool execution engine: stdout/stderr are drained fairly
330/// with bounded storage, and the deadline starts immediately before spawning
331/// and extends through pipe EOF. On failure, pipes are closed and the direct
332/// child is terminated/reaped, retaining the original failure and cleanup
333/// evidence. Descendants, platform setup hooks and inherited descriptor lifetimes
334/// remain caller-owned. Spawning, setup hooks and kill/reap are synchronous and
335/// may exceed the deadline. This does not supervise a process group, roll back
336/// an external effect or make an uncertain command safe to repeat.
337///
338/// # Errors
339/// Rejects an invalid deadline before touching or spawning the command. Spawn,
340/// capture, nonzero exit, overflow and deadline failures retain bounded evidence.
341pub fn capture_command(
342 command: &mut Command,
343 limits: OutputLimits,
344) -> Result<ExecutionEvidence, ToolError> {
345 validate_limits(limits)?;
346 process::capture_command(command, limits, process::CleanupScope::DirectChild)
347 .map_err(|source| ToolError::Execution(Box::new(source)))
348}
349
350/// Capture one caller-configured command in a newly owned process group.
351///
352/// Uses the same bounded, fair stdout/stderr capture and deadline as
353/// [`capture_command`], with null stdin and no executable admission or retries.
354/// Preserves caller command settings except IO and process-group selection;
355/// the latter is replaced with a new owned group. On natural leader exit,
356/// timeout, overflow or IO failure, remaining group members are signalled before
357/// reaping the leader. Original failures and group/direct-child cleanup errors
358/// remain separate in [`ExecutionError`]. There is no implicit background handoff.
359///
360/// Callers must not reap the leader independently or change its group. Escaped
361/// descendants are not contained, and signalling is not proof of descendant exit
362/// or completed external effects. Synchronous spawning/setup/cleanup can exceed
363/// the deadline. Admission, budgets, inherited descriptors and recovery remain
364/// caller-owned. Use [`capture_command`] for the existing direct-child contract.
365/// # Errors
366/// Rejects invalid deadlines before modifying/spawning the command. Execution
367/// failures retain bounded output, observed status and separate cleanup errors.
368pub fn capture_group_command(
369 command: &mut Command,
370 limits: OutputLimits,
371) -> Result<ExecutionEvidence, ToolError> {
372 validate_limits(limits)?;
373 process::capture_command(command, limits, process::CleanupScope::ProcessGroup)
374 .map_err(|source| ToolError::Execution(Box::new(source)))
375}
376
377/// Successful leader disposition during [`communicate_child`].
378#[derive(Clone, Copy, Debug, Eq, PartialEq)]
379pub enum SuccessfulExit {
380 /// Clean the owned group before reaping, then finish draining captured pipes.
381 Cleanup,
382 /// Reserve the successful leader through IO completion and caller admission.
383 /// The caller must then explicitly wait, terminate or hand off the owner.
384 Retain,
385}
386
387/// Communicate once with a caller-spawned child using its configured IO.
388///
389/// Uses the capture engine to fairly drain available stdout/stderr pipes and
390/// write borrowed input without blocking reader/writer threads. The caller
391/// configures IO before [`crate::child::OwnedChild::spawn`]: absent output pipes
392/// are left alone (for example inherited progress output or files). Limits only
393/// apply to captured pipes. Do not take the child's pipes before this call.
394/// `Some(input)`, including empty input, requires piped stdin. `None` closes any
395/// available stdin pipe immediately; inherited stdin remains caller-selected.
396/// Stdin closes after input is written. An early broken pipe is accepted, like
397/// standard communicate semantics; command status and diagnostics remain primary.
398/// This does not guarantee the command consumed all input or applied its effects.
399///
400/// The deadline starts on entry, excluding earlier spawn time. `cancelled` is
401/// polled between bounded IO steps and before returning success; it must return
402/// promptly. No signal handler, cancellation thread, retry or output decoding
403/// is installed. IO failure, cancellation, timeout, overflow and unsuccessful
404/// exit close pipes and terminate/reap through the child's existing owner,
405/// retaining original failure and separate cleanup errors.
406///
407/// On success, all owned pipes are closed. With [`SuccessfulExit::Cleanup`],
408/// group cleanup and reaping occur as soon as leader exit is observed. With
409/// [`SuccessfulExit::Retain`], the successful leader remains reserved and group
410/// cleanup remains armed. After admitting output and checking
411/// application cancellation/deadlines, the caller must choose ordinary
412/// [`crate::child::OwnedChild::wait`] cleanup or explicit
413/// [`crate::child::OwnedChild::handoff`]. Rejected output can use `terminate`
414/// to retain cleanup evidence. Dropping the owner provides best-effort cleanup.
415/// This is not process-tree confinement or a descendant-exit barrier.
416///
417/// # Errors
418/// Invalid deadlines leave the already spawned child and its pipes untouched;
419/// the caller retains cleanup responsibility. An already terminating/reaped/transferred
420/// owner is rejected instead of reporting reserved success. Other failures return execution
421/// evidence after cleanup. Cleanup uses the policy chosen at child spawn and
422/// can exceed the communication deadline by its separate grace/reap allowance.
423pub fn communicate_child(
424 child: &mut crate::child::OwnedChild,
425 input: Option<&[u8]>,
426 limits: OutputLimits,
427 successful_exit: SuccessfulExit,
428 cancelled: impl FnMut() -> bool,
429) -> Result<ExecutionEvidence, ToolError> {
430 communicate_child_with_observer(child, input, limits, successful_exit, cancelled, |_, _| {})
431}
432
433/// Communicate with an owned child while observing retained output bytes live.
434///
435/// Uses the same bounds, pipe handling and cleanup as [`communicate_child`].
436/// `output` receives nonempty borrowed chunks after retention, in order within
437/// each stream; chunk boundaries and ordering between streams are unspecified.
438/// The bytes concatenate to the corresponding returned evidence, including the
439/// retained prefix on overflow. Bytes beyond a selected limit are never reported.
440/// Output is raw bytes, not necessarily complete lines or UTF-8.
441///
442/// Both callbacks run synchronously and must return promptly. `cancelled` also
443/// runs while the child is silent and may project caller-owned heartbeat events;
444/// no exact callback cadence is guaranteed. Callback time counts toward the
445/// communication deadline. A callback panic closes the pipes, attempts cleanup
446/// using the child's policy and resumes the original unwind, even if the caller
447/// catches it while retaining the child. Cleanup errors cannot be returned during
448/// unwind; any unreaped child remains owned for caller recovery.
449///
450/// Callers own event schemas, scheduling, output budgets and diagnostic rendering.
451/// Observation does not change hard overflow into truncation-and-continue.
452///
453/// # Panics
454/// Resumes callback panics after attempting cleanup with the selected policy.
455///
456/// # Errors
457/// Returns the same validation and execution errors as [`communicate_child`].
458pub fn communicate_child_with_observer(
459 child: &mut crate::child::OwnedChild,
460 input: Option<&[u8]>,
461 limits: OutputLimits,
462 successful_exit: SuccessfulExit,
463 cancelled: impl FnMut() -> bool,
464 output: impl FnMut(OutputStream, &[u8]),
465) -> Result<ExecutionEvidence, ToolError> {
466 validate_limits(limits)?;
467 process::communicate(child, input, limits, successful_exit, cancelled, output)
468 .map_err(|source| ToolError::Execution(Box::new(source)))
469}
470
471impl fmt::Display for ToolError {
472 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473 match self {
474 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
475 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
476 Self::NotExecutable => f.write_str("tool file is not executable"),
477 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
478 Self::Execution(source) => source.fmt(f),
479 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
480 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
481 }
482 }
483}
484impl std::error::Error for ToolError {
485 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
486 match self {
487 Self::Io(source) => Some(source),
488 Self::Artifact(source) => Some(source),
489 Self::Execution(source) => Some(source.as_ref()),
490 Self::VersionUtf8 { source, .. } => Some(source),
491 _ => None,
492 }
493 }
494}
495
496/// An executable with a retained byte identity and an admitted exact version.
497///
498/// [`Self::admit`] requires a consumer-supplied digest. [`Self::admit_version`]
499/// records the installed identity without authenticating it against a pin.
500/// Every execution rechecks digest/permission before spawning. Consumers must
501/// exclude concurrent writers to the executable and its parent directories:
502/// filesystem checks and `exec` are separate operations. This is not a file
503/// capability or verification of dynamic libraries, interpreters, or descendants.
504pub struct AdmittedTool {
505 path: PathBuf,
506 identity: ArtifactIdentity,
507 executable_bytes: u64,
508 version_identity: String,
509}
510
511impl AdmittedTool {
512 /// Admit exact executable bytes before invoking the selected version command.
513 ///
514 /// # Errors
515 /// Rejects invalid inputs, non-executable files, digest/size mismatch, process
516 /// failures, non-UTF-8 stdout, and a different successful version identity.
517 pub fn admit(
518 spec: &ToolSpec<'_>,
519 context: &ExecutionContext<'_>,
520 limits: OutputLimits,
521 ) -> Result<Self, ToolError> {
522 Self::admit_with_digest(
523 &VersionSpec {
524 executable: spec.executable,
525 executable_bytes: spec.executable_bytes,
526 version_arguments: spec.version_arguments,
527 version_identity: spec.version_identity,
528 },
529 Some(spec.sha256),
530 context,
531 limits,
532 )
533 }
534
535 /// Admit an exact version and record the caller-trusted installed bytes.
536 ///
537 /// For tools built locally, no portable published digest may exist. This
538 /// entry hashes the executable within the supplied budget before running
539 /// the version command. The resulting [`Self::identity`] is an observation,
540 /// not a trusted published pin. All later runs reject changed bytes using
541 /// the same verification and capture engine as [`Self::admit`].
542 ///
543 /// The caller must trust the installation before admission: the version
544 /// command executes those bytes. Exact version output does not establish
545 /// authenticity. No version ranges, tool installation or PATH search occur.
546 /// The caller must exclude concurrent executable/directory writers.
547 ///
548 /// # Errors
549 /// Rejects invalid inputs, non-executable or oversized files, process
550 /// failures, non-UTF-8 stdout and a different successful version identity.
551 pub fn admit_version(
552 spec: &VersionSpec<'_>,
553 context: &ExecutionContext<'_>,
554 limits: OutputLimits,
555 ) -> Result<Self, ToolError> {
556 Self::admit_with_digest(spec, None, context, limits)
557 }
558
559 fn admit_with_digest(
560 spec: &VersionSpec<'_>,
561 expected: Option<Sha256Digest>,
562 context: &ExecutionContext<'_>,
563 limits: OutputLimits,
564 ) -> Result<Self, ToolError> {
565 if !spec.executable.is_absolute() {
566 return Err(ToolError::InvalidInvocation(
567 InvalidInvocation::ExecutablePath,
568 ));
569 }
570 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
571 {
572 return Err(ToolError::InvalidInvocation(
573 InvalidInvocation::VersionIdentity,
574 ));
575 }
576 validate_invocation(spec.version_arguments, context, limits)?;
577 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
578 let identity = verify_executable(&path, spec.executable_bytes, expected)?;
579 let evidence = process::capture(&path, spec.version_arguments, context, limits)
580 .map_err(|source| ToolError::Execution(Box::new(source)))?;
581 let version = match std::str::from_utf8(&evidence.stdout) {
582 Ok(version) => version.trim(),
583 Err(source) => {
584 return Err(ToolError::VersionUtf8 {
585 source,
586 evidence: Box::new(evidence),
587 });
588 }
589 };
590 if version != spec.version_identity {
591 return Err(ToolError::VersionMismatch {
592 evidence: Box::new(evidence),
593 });
594 }
595 Ok(Self {
596 path,
597 identity,
598 executable_bytes: spec.executable_bytes,
599 version_identity: spec.version_identity.to_owned(),
600 })
601 }
602
603 /// Canonical absolute path selected during admission.
604 #[must_use]
605 pub fn path(&self) -> &Path {
606 &self.path
607 }
608
609 /// Retained raw executable identity.
610 ///
611 /// With [`Self::admit_version`], this is an observed identity, not proof of
612 /// a published binary pin or trusted installation provenance.
613 #[must_use]
614 pub const fn identity(&self) -> ArtifactIdentity {
615 self.identity
616 }
617
618 /// Successfully observed, trimmed version identity.
619 #[must_use]
620 pub fn version_identity(&self) -> &str {
621 &self.version_identity
622 }
623
624 /// Run once with a cleared, explicitly supplied environment and null stdin.
625 ///
626 /// Stdout/stderr are drained fairly through nonblocking pipes without reader
627 /// threads. On overflow/deadline the direct child is killed and reaped; pipe
628 /// handles are closed without waiting for descendants to close their copies.
629 /// Descendant processes remain caller-owned. This must not be interpreted
630 /// as a rollback or safe automatic retry of a command with external effects.
631 ///
632 /// # Errors
633 /// Returns invalid-input or identity failures before execution, or an
634 /// execution failure retaining bounded prefixes and cleanup outcomes.
635 pub fn run(
636 &self,
637 arguments: &[OsString],
638 context: &ExecutionContext<'_>,
639 limits: OutputLimits,
640 ) -> Result<ExecutionEvidence, ToolError> {
641 validate_invocation(arguments, context, limits)?;
642 verify_executable(
643 &self.path,
644 self.executable_bytes,
645 Some(self.identity.sha256),
646 )?;
647 process::capture(&self.path, arguments, context, limits)
648 .map_err(|source| ToolError::Execution(Box::new(source)))
649 }
650}
651
652fn verify_executable(
653 path: &Path,
654 limit: u64,
655 expected: Option<Sha256Digest>,
656) -> Result<ArtifactIdentity, ToolError> {
657 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
658 if let Some(expected) = expected
659 && actual.sha256 != expected
660 {
661 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
662 expected,
663 actual,
664 }));
665 }
666 if fs::metadata(path)
667 .map_err(ToolError::Io)?
668 .permissions()
669 .mode()
670 & 0o111
671 == 0
672 {
673 return Err(ToolError::NotExecutable);
674 }
675 Ok(actual)
676}
677
678fn validate_invocation(
679 arguments: &[OsString],
680 context: &ExecutionContext<'_>,
681 limits: OutputLimits,
682) -> Result<(), ToolError> {
683 let reject = |input| ToolError::InvalidInvocation(input);
684 if !context.current_dir.is_absolute() {
685 return Err(reject(InvalidInvocation::WorkingDirectory));
686 }
687 validate_limits(limits)?;
688 for (index, argument) in arguments.iter().enumerate() {
689 if argument.as_bytes().contains(&0) {
690 return Err(reject(InvalidInvocation::Argument { index }));
691 }
692 }
693 for (index, (key, value)) in context.environment.iter().enumerate() {
694 if key.is_empty()
695 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
696 || context.environment[..index]
697 .iter()
698 .any(|(earlier, _)| earlier == key)
699 {
700 return Err(reject(InvalidInvocation::EnvironmentName { index }));
701 }
702 if value.as_bytes().contains(&0) {
703 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
704 }
705 }
706 Ok(())
707}
708
709fn validate_limits(limits: OutputLimits) -> Result<(), ToolError> {
710 if limits.timeout.is_zero()
711 || std::time::Instant::now()
712 .checked_add(limits.timeout)
713 .is_none()
714 {
715 return Err(ToolError::InvalidInvocation(InvalidInvocation::Deadline));
716 }
717 Ok(())
718}