1mod 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub struct OutputLimits {
27 pub stdout_bytes: usize,
29 pub stderr_bytes: usize,
31 pub timeout: Duration,
34}
35
36pub struct ExecutionContext<'a> {
42 pub current_dir: &'a Path,
44 pub environment: &'a [(OsString, OsString)],
47}
48
49pub struct ToolSpec<'a> {
51 pub executable: &'a Path,
53 pub sha256: Sha256Digest,
55 pub executable_bytes: u64,
57 pub version_arguments: &'a [OsString],
59 pub version_identity: &'a str,
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65pub enum InvalidInvocation {
66 ExecutablePath,
68 WorkingDirectory,
70 Deadline,
72 VersionIdentity,
74 Argument {
76 index: usize,
78 },
79 EnvironmentName {
81 index: usize,
83 },
84 EnvironmentValue {
86 index: usize,
88 },
89}
90
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
93pub enum OutputStream {
94 Stdout,
96 Stderr,
98}
99
100#[derive(Default)]
105pub struct ExecutionEvidence {
106 pub status: Option<ExitStatus>,
109 pub stdout: Vec<u8>,
111 pub stderr: Vec<u8>,
113 pub stdout_truncated: bool,
115 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub enum ExecutionOperation {
136 Spawn,
138 StdoutPipe,
140 StderrPipe,
142 ReadPipeFlags,
144 SetPipeFlags,
146 ReadOutput,
148 Wait,
150}
151
152impl fmt::Display for ExecutionOperation {
153 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154 f.write_str(match self {
155 Self::Spawn => "spawn",
156 Self::StdoutPipe => "stdout pipe",
157 Self::StderrPipe => "stderr pipe",
158 Self::ReadPipeFlags => "read pipe flags",
159 Self::SetPipeFlags => "set pipe flags",
160 Self::ReadOutput => "read output",
161 Self::Wait => "wait",
162 })
163 }
164}
165
166#[derive(Debug)]
168pub enum ExecutionFailure {
169 ExitStatus,
171 TimedOut,
173 OutputLimit {
175 stream: OutputStream,
177 },
178 Io {
180 operation: ExecutionOperation,
182 source: io::Error,
184 },
185 Allocation {
187 stream: OutputStream,
189 source: std::collections::TryReserveError,
191 },
192}
193
194#[derive(Debug)]
196pub struct ExecutionError {
197 pub failure: ExecutionFailure,
199 pub evidence: ExecutionEvidence,
201 pub kill_error: Option<io::Error>,
203 pub wait_error: Option<io::Error>,
205}
206
207impl fmt::Display for ExecutionError {
208 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209 match &self.failure {
210 ExecutionFailure::ExitStatus => {
211 write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
212 }
213 ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
214 ExecutionFailure::OutputLimit { stream } => {
215 write!(f, "tool {stream:?} exceeded its byte limit")
216 }
217 ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
218 ExecutionFailure::Allocation { stream, .. } => {
219 write!(f, "tool {stream:?} allocation failed")
220 }
221 }
222 }
223}
224impl std::error::Error for ExecutionError {
225 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
226 match &self.failure {
227 ExecutionFailure::Io { source, .. } => Some(source),
228 ExecutionFailure::Allocation { source, .. } => Some(source),
229 _ => None,
230 }
231 }
232}
233
234#[derive(Debug)]
236pub enum ToolError {
237 InvalidInvocation(InvalidInvocation),
239 Io(io::Error),
241 NotExecutable,
243 Artifact(ArtifactError),
245 Execution(Box<ExecutionError>),
247 VersionUtf8 {
249 source: std::str::Utf8Error,
251 evidence: Box<ExecutionEvidence>,
253 },
254 VersionMismatch {
256 evidence: Box<ExecutionEvidence>,
258 },
259}
260
261impl fmt::Display for ToolError {
262 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263 match self {
264 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
265 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
266 Self::NotExecutable => f.write_str("tool file is not executable"),
267 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
268 Self::Execution(source) => source.fmt(f),
269 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
270 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
271 }
272 }
273}
274impl std::error::Error for ToolError {
275 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
276 match self {
277 Self::Io(source) => Some(source),
278 Self::Artifact(source) => Some(source),
279 Self::Execution(source) => Some(source.as_ref()),
280 Self::VersionUtf8 { source, .. } => Some(source),
281 _ => None,
282 }
283 }
284}
285
286pub struct AdmittedTool {
293 path: PathBuf,
294 identity: ArtifactIdentity,
295 executable_bytes: u64,
296 version_identity: String,
297}
298
299impl AdmittedTool {
300 pub fn admit(
306 spec: &ToolSpec<'_>,
307 context: &ExecutionContext<'_>,
308 limits: OutputLimits,
309 ) -> Result<Self, ToolError> {
310 if !spec.executable.is_absolute() {
311 return Err(ToolError::InvalidInvocation(
312 InvalidInvocation::ExecutablePath,
313 ));
314 }
315 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
316 {
317 return Err(ToolError::InvalidInvocation(
318 InvalidInvocation::VersionIdentity,
319 ));
320 }
321 validate_invocation(spec.version_arguments, context, limits)?;
322 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
323 let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
324 let evidence = process::capture(&path, spec.version_arguments, context, limits)
325 .map_err(|source| ToolError::Execution(Box::new(source)))?;
326 let version = match std::str::from_utf8(&evidence.stdout) {
327 Ok(version) => version.trim(),
328 Err(source) => {
329 return Err(ToolError::VersionUtf8 {
330 source,
331 evidence: Box::new(evidence),
332 });
333 }
334 };
335 if version != spec.version_identity {
336 return Err(ToolError::VersionMismatch {
337 evidence: Box::new(evidence),
338 });
339 }
340 Ok(Self {
341 path,
342 identity,
343 executable_bytes: spec.executable_bytes,
344 version_identity: spec.version_identity.to_owned(),
345 })
346 }
347
348 #[must_use]
350 pub fn path(&self) -> &Path {
351 &self.path
352 }
353
354 #[must_use]
356 pub const fn identity(&self) -> ArtifactIdentity {
357 self.identity
358 }
359
360 #[must_use]
362 pub fn version_identity(&self) -> &str {
363 &self.version_identity
364 }
365
366 pub fn run(
378 &self,
379 arguments: &[OsString],
380 context: &ExecutionContext<'_>,
381 limits: OutputLimits,
382 ) -> Result<ExecutionEvidence, ToolError> {
383 validate_invocation(arguments, context, limits)?;
384 verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
385 process::capture(&self.path, arguments, context, limits)
386 .map_err(|source| ToolError::Execution(Box::new(source)))
387 }
388}
389
390fn verify_executable(
391 path: &Path,
392 limit: u64,
393 expected: Sha256Digest,
394) -> Result<ArtifactIdentity, ToolError> {
395 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
396 if actual.sha256 != expected {
397 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
398 expected,
399 actual,
400 }));
401 }
402 if fs::metadata(path)
403 .map_err(ToolError::Io)?
404 .permissions()
405 .mode()
406 & 0o111
407 == 0
408 {
409 return Err(ToolError::NotExecutable);
410 }
411 Ok(actual)
412}
413
414fn validate_invocation(
415 arguments: &[OsString],
416 context: &ExecutionContext<'_>,
417 limits: OutputLimits,
418) -> Result<(), ToolError> {
419 let reject = |input| ToolError::InvalidInvocation(input);
420 if !context.current_dir.is_absolute() {
421 return Err(reject(InvalidInvocation::WorkingDirectory));
422 }
423 if limits.timeout.is_zero()
424 || std::time::Instant::now()
425 .checked_add(limits.timeout)
426 .is_none()
427 {
428 return Err(reject(InvalidInvocation::Deadline));
429 }
430 for (index, argument) in arguments.iter().enumerate() {
431 if argument.as_bytes().contains(&0) {
432 return Err(reject(InvalidInvocation::Argument { index }));
433 }
434 }
435 for (index, (key, value)) in context.environment.iter().enumerate() {
436 if key.is_empty()
437 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
438 || context.environment[..index]
439 .iter()
440 .any(|(earlier, _)| earlier == key)
441 {
442 return Err(reject(InvalidInvocation::EnvironmentName { index }));
443 }
444 if value.as_bytes().contains(&0) {
445 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
446 }
447 }
448 Ok(())
449}