1mod 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::ExitStatus,
22 time::Duration,
23};
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
27pub struct OutputLimits {
28 pub stdout_bytes: usize,
30 pub stderr_bytes: usize,
32 pub timeout: Duration,
35}
36
37pub struct ExecutionContext<'a> {
43 pub current_dir: &'a Path,
45 pub environment: &'a [(OsString, OsString)],
48}
49
50pub struct ToolSpec<'a> {
52 pub executable: &'a Path,
54 pub sha256: Sha256Digest,
56 pub executable_bytes: u64,
58 pub version_arguments: &'a [OsString],
60 pub version_identity: &'a str,
62}
63
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub enum InvalidInvocation {
67 ExecutablePath,
69 WorkingDirectory,
71 Deadline,
73 VersionIdentity,
75 Argument {
77 index: usize,
79 },
80 EnvironmentName {
82 index: usize,
84 },
85 EnvironmentValue {
87 index: usize,
89 },
90}
91
92#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub enum OutputStream {
95 Stdout,
97 Stderr,
99}
100
101#[derive(Default)]
106pub struct ExecutionEvidence {
107 pub status: Option<ExitStatus>,
110 pub stdout: Vec<u8>,
112 pub stderr: Vec<u8>,
114 pub stdout_truncated: bool,
116 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
136pub enum ExecutionOperation {
137 Spawn,
139 StdoutPipe,
141 StderrPipe,
143 ReadPipeFlags,
145 SetPipeFlags,
147 ReadOutput,
149 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#[derive(Debug)]
169pub enum ExecutionFailure {
170 ExitStatus,
172 TimedOut,
174 OutputLimit {
176 stream: OutputStream,
178 },
179 Io {
181 operation: ExecutionOperation,
183 source: io::Error,
185 },
186 Allocation {
188 stream: OutputStream,
190 source: std::collections::TryReserveError,
192 },
193}
194
195#[derive(Debug)]
197pub struct ExecutionError {
198 pub failure: ExecutionFailure,
200 pub evidence: ExecutionEvidence,
202 pub kill_error: Option<io::Error>,
204 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#[derive(Debug)]
237pub enum ToolError {
238 InvalidInvocation(InvalidInvocation),
240 Io(io::Error),
242 NotExecutable,
244 Artifact(ArtifactError),
246 Execution(Box<ExecutionError>),
248 VersionUtf8 {
250 source: std::str::Utf8Error,
252 evidence: Box<ExecutionEvidence>,
254 },
255 VersionMismatch {
257 evidence: Box<ExecutionEvidence>,
259 },
260}
261
262impl fmt::Display for ToolError {
263 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264 match self {
265 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
266 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
267 Self::NotExecutable => f.write_str("tool file is not executable"),
268 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
269 Self::Execution(source) => source.fmt(f),
270 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
271 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
272 }
273 }
274}
275impl std::error::Error for ToolError {
276 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
277 match self {
278 Self::Io(source) => Some(source),
279 Self::Artifact(source) => Some(source),
280 Self::Execution(source) => Some(source.as_ref()),
281 Self::VersionUtf8 { source, .. } => Some(source),
282 _ => None,
283 }
284 }
285}
286
287pub struct AdmittedTool {
294 path: PathBuf,
295 identity: ArtifactIdentity,
296 executable_bytes: u64,
297 version_identity: String,
298}
299
300impl AdmittedTool {
301 pub fn admit(
307 spec: &ToolSpec<'_>,
308 context: &ExecutionContext<'_>,
309 limits: OutputLimits,
310 ) -> Result<Self, ToolError> {
311 if !spec.executable.is_absolute() {
312 return Err(ToolError::InvalidInvocation(
313 InvalidInvocation::ExecutablePath,
314 ));
315 }
316 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
317 {
318 return Err(ToolError::InvalidInvocation(
319 InvalidInvocation::VersionIdentity,
320 ));
321 }
322 validate_invocation(spec.version_arguments, context, limits)?;
323 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
324 let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
325 let evidence = process::capture(&path, spec.version_arguments, context, limits)
326 .map_err(|source| ToolError::Execution(Box::new(source)))?;
327 let version = match std::str::from_utf8(&evidence.stdout) {
328 Ok(version) => version.trim(),
329 Err(source) => {
330 return Err(ToolError::VersionUtf8 {
331 source,
332 evidence: Box::new(evidence),
333 });
334 }
335 };
336 if version != spec.version_identity {
337 return Err(ToolError::VersionMismatch {
338 evidence: Box::new(evidence),
339 });
340 }
341 Ok(Self {
342 path,
343 identity,
344 executable_bytes: spec.executable_bytes,
345 version_identity: spec.version_identity.to_owned(),
346 })
347 }
348
349 #[must_use]
351 pub fn path(&self) -> &Path {
352 &self.path
353 }
354
355 #[must_use]
357 pub const fn identity(&self) -> ArtifactIdentity {
358 self.identity
359 }
360
361 #[must_use]
363 pub fn version_identity(&self) -> &str {
364 &self.version_identity
365 }
366
367 pub fn run(
379 &self,
380 arguments: &[OsString],
381 context: &ExecutionContext<'_>,
382 limits: OutputLimits,
383 ) -> Result<ExecutionEvidence, ToolError> {
384 validate_invocation(arguments, context, limits)?;
385 verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
386 process::capture(&self.path, arguments, context, limits)
387 .map_err(|source| ToolError::Execution(Box::new(source)))
388 }
389}
390
391fn verify_executable(
392 path: &Path,
393 limit: u64,
394 expected: Sha256Digest,
395) -> Result<ArtifactIdentity, ToolError> {
396 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
397 if actual.sha256 != expected {
398 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
399 expected,
400 actual,
401 }));
402 }
403 if fs::metadata(path)
404 .map_err(ToolError::Io)?
405 .permissions()
406 .mode()
407 & 0o111
408 == 0
409 {
410 return Err(ToolError::NotExecutable);
411 }
412 Ok(actual)
413}
414
415fn validate_invocation(
416 arguments: &[OsString],
417 context: &ExecutionContext<'_>,
418 limits: OutputLimits,
419) -> Result<(), ToolError> {
420 let reject = |input| ToolError::InvalidInvocation(input);
421 if !context.current_dir.is_absolute() {
422 return Err(reject(InvalidInvocation::WorkingDirectory));
423 }
424 if limits.timeout.is_zero()
425 || std::time::Instant::now()
426 .checked_add(limits.timeout)
427 .is_none()
428 {
429 return Err(reject(InvalidInvocation::Deadline));
430 }
431 for (index, argument) in arguments.iter().enumerate() {
432 if argument.as_bytes().contains(&0) {
433 return Err(reject(InvalidInvocation::Argument { index }));
434 }
435 }
436 for (index, (key, value)) in context.environment.iter().enumerate() {
437 if key.is_empty()
438 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
439 || context.environment[..index]
440 .iter()
441 .any(|(earlier, _)| earlier == key)
442 {
443 return Err(reject(InvalidInvocation::EnvironmentName { index }));
444 }
445 if value.as_bytes().contains(&0) {
446 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
447 }
448 }
449 Ok(())
450}