mod process;
mod resolution;
#[cfg(test)]
mod tests;
pub use resolution::{ResolutionError, resolve_executable};
use ic_host_artifacts::artifact::{ArtifactError, ArtifactIdentity, Sha256Digest};
use ic_host_fs::read::hash_file;
use std::{
ffi::OsString,
fmt, fs, io,
os::unix::{ffi::OsStrExt as _, fs::PermissionsExt as _},
path::{Path, PathBuf},
process::{Command, ExitStatus},
time::Duration,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OutputLimits {
pub stdout_bytes: usize,
pub stderr_bytes: usize,
pub timeout: Duration,
}
pub struct ExecutionContext<'a> {
pub current_dir: &'a Path,
pub environment: &'a [(OsString, OsString)],
}
pub struct ToolSpec<'a> {
pub executable: &'a Path,
pub sha256: Sha256Digest,
pub executable_bytes: u64,
pub version_arguments: &'a [OsString],
pub version_identity: &'a str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum InvalidInvocation {
ExecutablePath,
WorkingDirectory,
Deadline,
VersionIdentity,
Argument {
index: usize,
},
EnvironmentName {
index: usize,
},
EnvironmentValue {
index: usize,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum OutputStream {
Stdout,
Stderr,
}
#[derive(Default)]
pub struct ExecutionEvidence {
pub status: Option<ExitStatus>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub stdout_truncated: bool,
pub stderr_truncated: bool,
}
impl fmt::Debug for ExecutionEvidence {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ExecutionEvidence")
.field("status", &self.status)
.field("stdout_bytes", &self.stdout.len())
.field("stderr_bytes", &self.stderr.len())
.field("stdout_truncated", &self.stdout_truncated)
.field("stderr_truncated", &self.stderr_truncated)
.finish()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionOperation {
Spawn,
StdoutPipe,
StderrPipe,
ReadPipeFlags,
SetPipeFlags,
ReadOutput,
Wait,
}
impl fmt::Display for ExecutionOperation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Spawn => "spawn",
Self::StdoutPipe => "stdout pipe",
Self::StderrPipe => "stderr pipe",
Self::ReadPipeFlags => "read pipe flags",
Self::SetPipeFlags => "set pipe flags",
Self::ReadOutput => "read output",
Self::Wait => "wait",
})
}
}
#[derive(Debug)]
pub enum ExecutionFailure {
ExitStatus,
TimedOut,
OutputLimit {
stream: OutputStream,
},
Io {
operation: ExecutionOperation,
source: io::Error,
},
Allocation {
stream: OutputStream,
source: std::collections::TryReserveError,
},
}
#[derive(Debug)]
pub struct ExecutionError {
pub failure: ExecutionFailure,
pub evidence: ExecutionEvidence,
pub kill_error: Option<io::Error>,
pub wait_error: Option<io::Error>,
}
impl fmt::Display for ExecutionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.failure {
ExecutionFailure::ExitStatus => {
write!(f, "tool exited unsuccessfully: {:?}", self.evidence.status)
}
ExecutionFailure::TimedOut => f.write_str("tool capture exceeded its deadline"),
ExecutionFailure::OutputLimit { stream } => {
write!(f, "tool {stream:?} exceeded its byte limit")
}
ExecutionFailure::Io { operation, .. } => write!(f, "tool {operation} failed"),
ExecutionFailure::Allocation { stream, .. } => {
write!(f, "tool {stream:?} allocation failed")
}
}
}
}
impl std::error::Error for ExecutionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.failure {
ExecutionFailure::Io { source, .. } => Some(source),
ExecutionFailure::Allocation { source, .. } => Some(source),
_ => None,
}
}
}
#[derive(Debug)]
pub enum ToolError {
InvalidInvocation(InvalidInvocation),
Io(io::Error),
NotExecutable,
Artifact(ArtifactError),
Execution(Box<ExecutionError>),
VersionUtf8 {
source: std::str::Utf8Error,
evidence: Box<ExecutionEvidence>,
},
VersionMismatch {
evidence: Box<ExecutionEvidence>,
},
}
impl ToolError {
#[must_use]
pub fn evidence(&self) -> Option<&ExecutionEvidence> {
match self {
Self::Execution(error) => Some(&error.evidence),
Self::VersionUtf8 { evidence, .. } | Self::VersionMismatch { evidence } => {
Some(evidence)
}
Self::InvalidInvocation(_) | Self::Io(_) | Self::NotExecutable | Self::Artifact(_) => {
None
}
}
}
#[must_use]
pub fn execution_error(&self) -> Option<&ExecutionError> {
match self {
Self::Execution(error) => Some(error),
_ => None,
}
}
}
pub fn capture_command(
command: &mut Command,
limits: OutputLimits,
) -> Result<ExecutionEvidence, ToolError> {
validate_limits(limits)?;
process::capture_command(command, limits)
.map_err(|source| ToolError::Execution(Box::new(source)))
}
impl fmt::Display for ToolError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidInvocation(input) => write!(f, "invalid tool invocation: {input:?}"),
Self::Io(_) => f.write_str("tool filesystem inspection failed"),
Self::NotExecutable => f.write_str("tool file is not executable"),
Self::Artifact(source) => write!(f, "tool identity verification failed: {source}"),
Self::Execution(source) => source.fmt(f),
Self::VersionUtf8 { .. } => f.write_str("tool version output is not UTF-8"),
Self::VersionMismatch { .. } => f.write_str("tool version does not match authority"),
}
}
}
impl std::error::Error for ToolError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(source) => Some(source),
Self::Artifact(source) => Some(source),
Self::Execution(source) => Some(source.as_ref()),
Self::VersionUtf8 { source, .. } => Some(source),
_ => None,
}
}
}
pub struct AdmittedTool {
path: PathBuf,
identity: ArtifactIdentity,
executable_bytes: u64,
version_identity: String,
}
impl AdmittedTool {
pub fn admit(
spec: &ToolSpec<'_>,
context: &ExecutionContext<'_>,
limits: OutputLimits,
) -> Result<Self, ToolError> {
if !spec.executable.is_absolute() {
return Err(ToolError::InvalidInvocation(
InvalidInvocation::ExecutablePath,
));
}
if spec.version_identity.is_empty() || spec.version_identity.trim() != spec.version_identity
{
return Err(ToolError::InvalidInvocation(
InvalidInvocation::VersionIdentity,
));
}
validate_invocation(spec.version_arguments, context, limits)?;
let path = fs::canonicalize(spec.executable).map_err(ToolError::Io)?;
let identity = verify_executable(&path, spec.executable_bytes, spec.sha256)?;
let evidence = process::capture(&path, spec.version_arguments, context, limits)
.map_err(|source| ToolError::Execution(Box::new(source)))?;
let version = match std::str::from_utf8(&evidence.stdout) {
Ok(version) => version.trim(),
Err(source) => {
return Err(ToolError::VersionUtf8 {
source,
evidence: Box::new(evidence),
});
}
};
if version != spec.version_identity {
return Err(ToolError::VersionMismatch {
evidence: Box::new(evidence),
});
}
Ok(Self {
path,
identity,
executable_bytes: spec.executable_bytes,
version_identity: spec.version_identity.to_owned(),
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub const fn identity(&self) -> ArtifactIdentity {
self.identity
}
#[must_use]
pub fn version_identity(&self) -> &str {
&self.version_identity
}
pub fn run(
&self,
arguments: &[OsString],
context: &ExecutionContext<'_>,
limits: OutputLimits,
) -> Result<ExecutionEvidence, ToolError> {
validate_invocation(arguments, context, limits)?;
verify_executable(&self.path, self.executable_bytes, self.identity.sha256)?;
process::capture(&self.path, arguments, context, limits)
.map_err(|source| ToolError::Execution(Box::new(source)))
}
}
fn verify_executable(
path: &Path,
limit: u64,
expected: Sha256Digest,
) -> Result<ArtifactIdentity, ToolError> {
let actual = hash_file(path, limit).map_err(ToolError::Artifact)?;
if actual.sha256 != expected {
return Err(ToolError::Artifact(ArtifactError::DigestMismatch {
expected,
actual,
}));
}
if fs::metadata(path)
.map_err(ToolError::Io)?
.permissions()
.mode()
& 0o111
== 0
{
return Err(ToolError::NotExecutable);
}
Ok(actual)
}
fn validate_invocation(
arguments: &[OsString],
context: &ExecutionContext<'_>,
limits: OutputLimits,
) -> Result<(), ToolError> {
let reject = |input| ToolError::InvalidInvocation(input);
if !context.current_dir.is_absolute() {
return Err(reject(InvalidInvocation::WorkingDirectory));
}
validate_limits(limits)?;
for (index, argument) in arguments.iter().enumerate() {
if argument.as_bytes().contains(&0) {
return Err(reject(InvalidInvocation::Argument { index }));
}
}
for (index, (key, value)) in context.environment.iter().enumerate() {
if key.is_empty()
|| key.as_bytes().iter().any(|byte| matches!(byte, b'=' | 0))
|| context.environment[..index]
.iter()
.any(|(earlier, _)| earlier == key)
{
return Err(reject(InvalidInvocation::EnvironmentName { index }));
}
if value.as_bytes().contains(&0) {
return Err(reject(InvalidInvocation::EnvironmentValue { index }));
}
}
Ok(())
}
fn validate_limits(limits: OutputLimits) -> Result<(), ToolError> {
if limits.timeout.is_zero()
|| std::time::Instant::now()
.checked_add(limits.timeout)
.is_none()
{
return Err(ToolError::InvalidInvocation(InvalidInvocation::Deadline));
}
Ok(())
}