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(Debug)]
133pub enum ExecutionFailure {
134 ExitStatus,
136 TimedOut,
138 OutputLimit {
140 stream: OutputStream,
142 },
143 Io {
145 operation: &'static str,
147 source: io::Error,
149 },
150 Allocation {
152 stream: OutputStream,
154 source: std::collections::TryReserveError,
156 },
157}
158
159#[derive(Debug)]
161pub struct ExecutionError {
162 pub failure: ExecutionFailure,
164 pub evidence: ExecutionEvidence,
166 pub kill_error: Option<io::Error>,
168 pub wait_error: Option<io::Error>,
170}
171
172impl fmt::Display for ExecutionError {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match &self.failure {
175 ExecutionFailure::ExitStatus => {
176 write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
177 }
178 ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
179 ExecutionFailure::OutputLimit { stream } => {
180 write!(f, "tool {stream:?} exceeded its byte limit")
181 }
182 ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
183 ExecutionFailure::Allocation { stream, .. } => {
184 write!(f, "tool {stream:?} allocation failed")
185 }
186 }
187 }
188}
189impl std::error::Error for ExecutionError {
190 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
191 match &self.failure {
192 ExecutionFailure::Io { source, .. } => Some(source),
193 ExecutionFailure::Allocation { source, .. } => Some(source),
194 _ => None,
195 }
196 }
197}
198
199#[derive(Debug)]
201pub enum ToolError {
202 InvalidInvocation(InvalidInvocation),
204 Io(io::Error),
206 NotExecutable,
208 Artifact(ArtifactError),
210 Execution(Box<ExecutionError>),
212 VersionUtf8 {
214 source: std::str::Utf8Error,
216 evidence: Box<ExecutionEvidence>,
218 },
219 VersionMismatch {
221 evidence: Box<ExecutionEvidence>,
223 },
224}
225
226impl fmt::Display for ToolError {
227 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228 match self {
229 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
230 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
231 Self::NotExecutable => f.write_str("tool file is not executable"),
232 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
233 Self::Execution(source) => source.fmt(f),
234 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
235 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
236 }
237 }
238}
239impl std::error::Error for ToolError {
240 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
241 match self {
242 Self::Io(source) => Some(source),
243 Self::Artifact(source) => Some(source),
244 Self::Execution(source) => Some(source.as_ref()),
245 Self::VersionUtf8 { source, .. } => Some(source),
246 _ => None,
247 }
248 }
249}
250
251pub struct AdmittedTool {
258 path: PathBuf,
259 identity: ArtifactIdentity,
260 executable_bytes: u64,
261 version_identity: String,
262}
263
264impl AdmittedTool {
265 pub fn admit(
271 spec: &ToolSpec<'_>,
272 context: &ExecutionContext<'_>,
273 limits: OutputLimits,
274 ) -> Result<Self, ToolError> {
275 if !spec.executable.is_absolute() {
276 return Err(ToolError::InvalidInvocation(
277 InvalidInvocation::ExecutablePath,
278 ));
279 }
280 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
281 {
282 return Err(ToolError::InvalidInvocation(
283 InvalidInvocation::VersionIdentity,
284 ));
285 }
286 validate_invocation(spec.version_arguments, context, limits)?;
287 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
288 let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
289 let evidence = process::capture(&path, spec.version_arguments, context, limits)
290 .map_err(|source| ToolError::Execution(Box::new(source)))?;
291 let version = match std::str::from_utf8(&evidence.stdout) {
292 Ok(version) => version.trim(),
293 Err(source) => {
294 return Err(ToolError::VersionUtf8 {
295 source,
296 evidence: Box::new(evidence),
297 });
298 }
299 };
300 if version != spec.version_identity {
301 return Err(ToolError::VersionMismatch {
302 evidence: Box::new(evidence),
303 });
304 }
305 Ok(Self {
306 path,
307 identity,
308 executable_bytes: spec.executable_bytes,
309 version_identity: spec.version_identity.to_owned(),
310 })
311 }
312
313 #[must_use]
315 pub fn path(&self) -> &Path {
316 &self.path
317 }
318
319 #[must_use]
321 pub const fn identity(&self) -> ArtifactIdentity {
322 self.identity
323 }
324
325 #[must_use]
327 pub fn version_identity(&self) -> &str {
328 &self.version_identity
329 }
330
331 pub fn run(
343 &self,
344 arguments: &[OsString],
345 context: &ExecutionContext<'_>,
346 limits: OutputLimits,
347 ) -> Result<ExecutionEvidence, ToolError> {
348 validate_invocation(arguments, context, limits)?;
349 verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
350 process::capture(&self.path, arguments, context, limits)
351 .map_err(|source| ToolError::Execution(Box::new(source)))
352 }
353}
354
355fn verify_executable(
356 path: &Path,
357 limit: u64,
358 expected: Sha256Digest,
359) -> Result<ArtifactIdentity, ToolError> {
360 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
361 if actual.sha256 != expected {
362 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
363 expected,
364 actual,
365 }));
366 }
367 if fs::metadata(path)
368 .map_err(ToolError::Io)?
369 .permissions()
370 .mode()
371 & 0o111
372 == 0
373 {
374 return Err(ToolError::NotExecutable);
375 }
376 Ok(actual)
377}
378
379fn validate_invocation(
380 arguments: &[OsString],
381 context: &ExecutionContext<'_>,
382 limits: OutputLimits,
383) -> Result<(), ToolError> {
384 let reject = |input| ToolError::InvalidInvocation(input);
385 if !context.current_dir.is_absolute() {
386 return Err(reject(InvalidInvocation::WorkingDirectory));
387 }
388 if limits.timeout.is_zero()
389 || std::time::Instant::now()
390 .checked_add(limits.timeout)
391 .is_none()
392 {
393 return Err(reject(InvalidInvocation::Deadline));
394 }
395 for (index, argument) in arguments.iter().enumerate() {
396 if argument.as_bytes().contains(&0) {
397 return Err(reject(InvalidInvocation::Argument { index }));
398 }
399 }
400 for (index, (key, value)) in context.environment.iter().enumerate() {
401 if key.is_empty()
402 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
403 || context.environment[..index]
404 .iter()
405 .any(|(earlier, _)| earlier == key)
406 {
407 return Err(reject(InvalidInvocation::EnvironmentName { index }));
408 }
409 if value.as_bytes().contains(&0) {
410 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
411 }
412 }
413 Ok(())
414}