1mod process;
8#[cfg(test)]
9mod tests;
10
11use crate::artifact::{ArtifactError, ArtifactIdentity, Sha256Digest, hash_file};
12use std::{
13 ffi::OsString,
14 fmt, fs, io,
15 os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _},
16 path::{Path, PathBuf},
17 process::ExitStatus,
18 time::Duration,
19};
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct OutputLimits {
24 pub stdout_bytes: usize,
26 pub stderr_bytes: usize,
28 pub timeout: Duration,
31}
32
33pub struct ExecutionContext<'a> {
39 pub current_dir: &'a Path,
41 pub environment: &'a [(OsString, OsString)],
44}
45
46pub struct ToolSpec<'a> {
48 pub executable: &'a Path,
50 pub sha256: Sha256Digest,
52 pub executable_bytes: u64,
54 pub version_arguments: &'a [OsString],
56 pub version_identity: &'a str,
58}
59
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
62pub enum InvalidInvocation {
63 ExecutablePath,
65 WorkingDirectory,
67 Deadline,
69 VersionIdentity,
71 Argument {
73 index: usize,
75 },
76 EnvironmentName {
78 index: usize,
80 },
81 EnvironmentValue {
83 index: usize,
85 },
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
90pub enum OutputStream {
91 Stdout,
93 Stderr,
95}
96
97#[derive(Default)]
102pub struct ExecutionEvidence {
103 pub status: Option<ExitStatus>,
106 pub stdout: Vec<u8>,
108 pub stderr: Vec<u8>,
110 pub stdout_truncated: bool,
112 pub stderr_truncated: bool,
114}
115
116impl fmt::Debug for ExecutionEvidence {
117 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118 f.debug_struct("ExecutionEvidence")
119 .field("status", &self.status)
120 .field("stdout_bytes", &self.stdout.len())
121 .field("stderr_bytes", &self.stderr.len())
122 .field("stdout_truncated", &self.stdout_truncated)
123 .field("stderr_truncated", &self.stderr_truncated)
124 .finish()
125 }
126}
127
128#[derive(Debug)]
130pub enum ExecutionFailure {
131 ExitStatus,
133 TimedOut,
135 OutputLimit {
137 stream: OutputStream,
139 },
140 Io {
142 operation: &'static str,
144 source: io::Error,
146 },
147 Allocation {
149 stream: OutputStream,
151 source: std::collections::TryReserveError,
153 },
154}
155
156#[derive(Debug)]
158pub struct ExecutionError {
159 pub failure: ExecutionFailure,
161 pub evidence: ExecutionEvidence,
163 pub kill_error: Option<io::Error>,
165 pub wait_error: Option<io::Error>,
167}
168
169impl fmt::Display for ExecutionError {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 match &self.failure {
172 ExecutionFailure::ExitStatus => {
173 write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
174 }
175 ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
176 ExecutionFailure::OutputLimit { stream } => {
177 write!(f, "tool {stream:?} exceeded its byte limit")
178 }
179 ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
180 ExecutionFailure::Allocation { stream, .. } => {
181 write!(f, "tool {stream:?} allocation failed")
182 }
183 }
184 }
185}
186impl std::error::Error for ExecutionError {
187 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
188 match &self.failure {
189 ExecutionFailure::Io { source, .. } => Some(source),
190 ExecutionFailure::Allocation { source, .. } => Some(source),
191 _ => None,
192 }
193 }
194}
195
196#[derive(Debug)]
198pub enum ToolError {
199 InvalidInvocation(InvalidInvocation),
201 Io(io::Error),
203 NotExecutable,
205 Artifact(ArtifactError),
207 Execution(Box<ExecutionError>),
209 VersionUtf8 {
211 source: std::str::Utf8Error,
213 evidence: Box<ExecutionEvidence>,
215 },
216 VersionMismatch {
218 evidence: Box<ExecutionEvidence>,
220 },
221}
222
223impl fmt::Display for ToolError {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 match self {
226 Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
227 Self::Io(_) => f.write_str("tool filesystem inspection failed"),
228 Self::NotExecutable => f.write_str("tool file is not executable"),
229 Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
230 Self::Execution(source) => source.fmt(f),
231 Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
232 Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
233 }
234 }
235}
236impl std::error::Error for ToolError {
237 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
238 match self {
239 Self::Io(source) => Some(source),
240 Self::Artifact(source) => Some(source),
241 Self::Execution(source) => Some(source.as_ref()),
242 Self::VersionUtf8 { source, .. } => Some(source),
243 _ => None,
244 }
245 }
246}
247
248pub struct AdmittedTool {
255 path: PathBuf,
256 identity: ArtifactIdentity,
257 executable_bytes: u64,
258 version_identity: String,
259}
260
261impl AdmittedTool {
262 pub fn admit(
268 spec: &ToolSpec<'_>,
269 context: &ExecutionContext<'_>,
270 limits: OutputLimits,
271 ) -> Result<Self, ToolError> {
272 if !spec.executable.is_absolute() {
273 return Err(ToolError::InvalidInvocation(
274 InvalidInvocation::ExecutablePath,
275 ));
276 }
277 if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
278 {
279 return Err(ToolError::InvalidInvocation(
280 InvalidInvocation::VersionIdentity,
281 ));
282 }
283 validate_invocation(spec.version_arguments, context, limits)?;
284 let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
285 let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
286 let evidence = process::capture(&path, spec.version_arguments, context, limits)
287 .map_err(|source| ToolError::Execution(Box::new(source)))?;
288 let version = match std::str::from_utf8(&evidence.stdout) {
289 Ok(version) => version.trim(),
290 Err(source) => {
291 return Err(ToolError::VersionUtf8 {
292 source,
293 evidence: Box::new(evidence),
294 });
295 }
296 };
297 if version != spec.version_identity {
298 return Err(ToolError::VersionMismatch {
299 evidence: Box::new(evidence),
300 });
301 }
302 Ok(Self {
303 path,
304 identity,
305 executable_bytes: spec.executable_bytes,
306 version_identity: spec.version_identity.to_owned(),
307 })
308 }
309
310 #[must_use]
312 pub fn path(&self) -> &Path {
313 &self.path
314 }
315
316 #[must_use]
318 pub const fn identity(&self) -> ArtifactIdentity {
319 self.identity
320 }
321
322 #[must_use]
324 pub fn version_identity(&self) -> &str {
325 &self.version_identity
326 }
327
328 pub fn run(
340 &self,
341 arguments: &[OsString],
342 context: &ExecutionContext<'_>,
343 limits: OutputLimits,
344 ) -> Result<ExecutionEvidence, ToolError> {
345 validate_invocation(arguments, context, limits)?;
346 verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
347 process::capture(&self.path, arguments, context, limits)
348 .map_err(|source| ToolError::Execution(Box::new(source)))
349 }
350}
351
352fn verify_executable(
353 path: &Path,
354 limit: u64,
355 expected: Sha256Digest,
356) -> Result<ArtifactIdentity, ToolError> {
357 let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
358 if actual.sha256 != expected {
359 return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
360 expected,
361 actual,
362 }));
363 }
364 if fs::metadata(path)
365 .map_err(ToolError::Io)?
366 .permissions()
367 .mode()
368 & 0o111
369 == 0
370 {
371 return Err(ToolError::NotExecutable);
372 }
373 Ok(actual)
374}
375
376fn validate_invocation(
377 arguments: &[OsString],
378 context: &ExecutionContext<'_>,
379 limits: OutputLimits,
380) -> Result<(), ToolError> {
381 let reject = |input| ToolError::InvalidInvocation(input);
382 if !context.current_dir.is_absolute() {
383 return Err(reject(InvalidInvocation::WorkingDirectory));
384 }
385 if limits.timeout.is_zero()
386 || std::time::Instant::now()
387 .checked_add(limits.timeout)
388 .is_none()
389 {
390 return Err(reject(InvalidInvocation::Deadline));
391 }
392 for (index, argument) in arguments.iter().enumerate() {
393 if argument.as_bytes().contains(&0) {
394 return Err(reject(InvalidInvocation::Argument { index }));
395 }
396 }
397 for (index, (key, value)) in context.environment.iter().enumerate() {
398 if key.is_empty()
399 || key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
400 || context.environment[..index]
401 .iter()
402 .any(|(earlier, _)| earlier == key)
403 {
404 return Err(reject(InvalidInvocation::EnvironmentName { index }));
405 }
406 if value.as_bytes().contains(&0) {
407 return Err(reject(InvalidInvocation::EnvironmentValue { index }));
408 }
409 }
410 Ok(())
411}