use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::process::Command;
use crate::error::{Error, Result};
use crate::observe::{EventKind, RunEvent};
use crate::policy::{Act, Effect, Policy};
use crate::run::{refused, Watch};
use crate::sandbox::{self, RunSpec, Sandbox, SandboxConfig};
use crate::state::{PolicyEvent, SandboxEvent, Store};
#[derive(Debug, Clone)]
pub enum Verification {
FileContains(String),
FileEquals(String),
CompilesRust,
RustTestPasses {
test_src: String,
},
WorkspaceFileContains {
file: PathBuf,
needle: String,
},
DocumentContains {
file: PathBuf,
needle: String,
},
EachCompilesRust(Vec<PathBuf>),
WorkspaceTestPasses {
files: Vec<PathBuf>,
test_src: String,
},
}
pub struct ExecGuard<'a> {
policy: &'a Policy,
trace: Option<(&'a Store, i64, u32)>,
watch: Option<(&'a Watch<'a>, u32)>,
sandbox: Option<SandboxConfig>,
}
impl<'a> ExecGuard<'a> {
pub fn new(policy: &'a Policy) -> Self {
Self {
policy,
trace: None,
watch: None,
sandbox: Some(SandboxConfig::default()),
}
}
pub fn tracing(mut self, store: &'a Store, run_id: i64, step: u32) -> Self {
self.trace = Some((store, run_id, step));
self
}
pub(crate) fn watching(mut self, watch: &'a Watch<'a>, depth: u32) -> Self {
self.watch = Some((watch, depth));
self
}
pub fn sandboxed(mut self, config: SandboxConfig) -> Self {
self.sandbox = Some(config);
self
}
pub fn no_sandbox(mut self) -> Self {
self.sandbox = None;
self
}
fn permissive() -> ExecGuard<'static> {
static PERMISSIVE: std::sync::OnceLock<Policy> = std::sync::OnceLock::new();
ExecGuard {
policy: PERMISSIVE.get_or_init(Policy::permissive),
trace: None,
watch: None,
sandbox: Some(SandboxConfig::default()),
}
}
fn check(&self, program: &str, argv: &[String]) -> Result<()> {
let verdict = self.policy.check(Act::Exec, program);
let full = format!("{program} {}", argv.join(" "));
if let Some((store, run_id, step)) = self.trace {
let mut ev = if verdict.effect == Effect::Allow {
PolicyEvent::decision(step, "exec", &full, "allow", "policy")
} else {
PolicyEvent::refusal(step, "exec", &full)
};
ev.rule = verdict.rule.clone();
ev.layer = verdict.layer.clone();
let _ = store.record_event(run_id, &ev);
if verdict.effect != Effect::Allow {
if let Some((watch, depth)) = self.watch {
refused(watch, run_id, depth, &ev);
}
}
}
if verdict.effect == Effect::Allow {
Ok(())
} else {
Err(Error::Refused {
act: "exec".into(),
target: program.to_string(),
rule: verdict.rule,
layer: verdict.layer,
})
}
}
fn record_gate_failure(&self, phase: &str) {
if let Some((store, run_id, step)) = self.trace {
self.sandboxed_event(store, &SandboxEvent::gate_phase_failed(run_id, step, phase));
}
}
fn sandboxed_event(&self, store: &Store, e: &SandboxEvent) {
let _ = store.record_sandbox_event(e);
if let Some((watch, depth)) = self.watch {
watch.emit(RunEvent::at_depth(
e.run_id,
e.step,
depth,
EventKind::Sandbox {
kind: e.kind.clone(),
backend: e.backend.clone(),
},
));
}
}
async fn exec(&self, argv: &[String], workdir: &Path) -> Result<bool> {
match &self.sandbox {
Some(cfg) => {
let sb = sandbox::select(cfg);
let backend = sb.backend();
if let Some((store, run_id, step)) = self.trace {
self.sandboxed_event(
store,
&SandboxEvent::create(run_id, step, backend.as_str()),
);
self.sandboxed_event(
store,
&SandboxEvent::exec(run_id, step, backend.as_str(), &argv.join(" ")),
);
}
let outcome = sb
.run(RunSpec {
argv,
workdir,
limits: &cfg.limits,
allow_network: cfg.allow_network,
})
.await?;
if let Some((store, run_id, step)) = self.trace {
if let Some(cap) = outcome.cap_hit {
self.sandboxed_event(
store,
&SandboxEvent::cap_hit(run_id, step, cap.as_str()),
);
}
self.sandboxed_event(store, &SandboxEvent::destroy(run_id, step));
}
if !outcome.success() {
tracing::debug!(
backend = backend.as_str(),
exit_code = ?outcome.exit_code,
cap_hit = ?outcome.cap_hit.map(|c| c.as_str()),
stderr = %outcome.stderr.trim(),
"sandboxed command failed"
);
}
Ok(outcome.success())
}
None => {
let out = Command::new(&argv[0])
.args(&argv[1..])
.current_dir(workdir)
.stdin(Stdio::null())
.output()
.await?;
if !out.status.success() {
tracing::debug!(
exit_code = ?out.status.code(),
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"host command failed"
);
}
Ok(out.status.success())
}
}
}
}
pub const TEST_BINARY: &str = "<test-binary>";
impl Verification {
pub async fn passes(&self, path: &Path, contents: &str) -> Result<bool> {
self.passes_guarded(path, contents, &ExecGuard::permissive())
.await
}
pub async fn passes_guarded(
&self,
_path: &Path,
contents: &str,
guard: &ExecGuard<'_>,
) -> Result<bool> {
match self {
Verification::FileContains(needle) => Ok(contents.contains(needle)),
Verification::FileEquals(expected) => Ok(contents == expected),
Verification::CompilesRust => compile_source(contents, None, guard).await,
Verification::RustTestPasses { test_src } => {
compile_source(contents, Some(test_src), guard).await
}
Verification::EachCompilesRust(_)
| Verification::WorkspaceTestPasses { .. }
| Verification::DocumentContains { .. }
| Verification::WorkspaceFileContains { .. } => Err(Error::Config(
"multi-file verification requires a workspace root".into(),
)),
}
}
pub async fn passes_in(&self, root: &Path) -> Result<bool> {
self.passes_in_guarded(root, &ExecGuard::permissive()).await
}
pub async fn passes_in_guarded(&self, root: &Path, guard: &ExecGuard<'_>) -> Result<bool> {
match self {
Verification::WorkspaceFileContains { file, needle } => {
let src = tokio::fs::read_to_string(root.join(file))
.await
.unwrap_or_default();
Ok(src.contains(needle))
}
Verification::DocumentContains { file, needle } => {
Ok(extract_document_text(root, file)?.contains(needle))
}
Verification::EachCompilesRust(files) => {
for f in files {
let src = tokio::fs::read_to_string(root.join(f))
.await
.unwrap_or_default();
if !compile_source(&src, None, guard).await? {
return Ok(false);
}
}
Ok(true)
}
Verification::WorkspaceTestPasses { files, test_src } => {
let mut combined = String::new();
for f in files {
let src = tokio::fs::read_to_string(root.join(f))
.await
.unwrap_or_default();
combined.push_str(&src);
combined.push('\n');
}
compile_source(&combined, Some(test_src), guard).await
}
_ => Err(Error::Config(
"single-file verification used in workspace mode".into(),
)),
}
}
pub fn describe(&self) -> String {
match self {
Verification::FileContains(needle) => {
format!("the file must contain exactly this text: {needle:?}")
}
Verification::FileEquals(expected) => {
format!("the file's entire contents must equal exactly: {expected:?}")
}
Verification::CompilesRust => {
"the file must compile as Rust (rustc --crate-type lib)".to_string()
}
Verification::RustTestPasses { test_src } => {
format!("the file must compile and pass this test:\n{test_src}")
}
Verification::WorkspaceFileContains { file, needle } => {
format!("the file {file:?} must contain exactly this text: {needle:?}")
}
Verification::DocumentContains { file, needle } => format!(
"the document {file:?} must contain this text once its text is \
extracted: {needle:?}"
),
Verification::EachCompilesRust(files) => {
format!("each of these files must compile as Rust: {files:?}")
}
Verification::WorkspaceTestPasses { files, test_src } => format!(
"these files {files:?} must together compile and pass this test:\n{test_src}"
),
}
}
}
#[allow(dead_code)]
fn missing_feature(ext: &str) -> Error {
Error::Config(format!(
"DocumentContains cannot read .{ext}: this build of io-harness does not \
have the \"{ext}\" feature enabled"
))
}
fn extract_document_text(root: &Path, file: &Path) -> Result<String> {
#[allow(unused_variables)]
let rel = file.to_string_lossy().replace('\\', "/");
#[allow(unused_variables)]
let ws = crate::tools::Workspace::new(root);
let ext = file
.extension()
.map(|e| e.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
match ext.as_str() {
#[cfg(feature = "xlsx")]
"xlsx" => crate::tools::documents::xlsx::read_sheet(&ws, &rel, None),
#[cfg(feature = "docx")]
"docx" => crate::tools::documents::docx::read_text(&ws, &rel),
#[cfg(feature = "pptx")]
"pptx" => crate::tools::documents::pptx::read_text(&ws, &rel),
#[cfg(feature = "pdf")]
"pdf" => crate::tools::documents::pdf::read_text(&ws, &rel),
#[cfg(not(feature = "xlsx"))]
"xlsx" => Err(missing_feature("xlsx")),
#[cfg(not(feature = "docx"))]
"docx" => Err(missing_feature("docx")),
#[cfg(not(feature = "pptx"))]
"pptx" => Err(missing_feature("pptx")),
#[cfg(not(feature = "pdf"))]
"pdf" => Err(missing_feature("pdf")),
other => Err(Error::Config(format!(
"DocumentContains does not know how to read .{other}; it reads .xlsx, \
.docx, .pptx and .pdf, and deliberately does not fall back to \
matching raw bytes"
))),
}
}
fn subject_lib_args(subject: &Path, rlib: &Path) -> Vec<String> {
[
"--edition",
"2021",
"--crate-type",
"lib",
"--crate-name",
SUBJECT_CRATE,
]
.iter()
.map(|s| s.to_string())
.chain([
subject.display().to_string(),
"-o".into(),
rlib.display().to_string(),
])
.collect()
}
fn test_build_args(combined: &Path, bin: &Path) -> Vec<String> {
["--edition", "2021", "--test"]
.iter()
.map(|s| s.to_string())
.chain([
combined.display().to_string(),
"-o".into(),
bin.display().to_string(),
])
.collect()
}
fn probe_args(dir: &Path, probe: &Path, rlib: &Path) -> Vec<String> {
[
"--edition",
"2021",
"--crate-type",
"lib",
"--emit",
"metadata",
"--extern",
]
.iter()
.map(|s| s.to_string())
.chain([
format!("{SUBJECT_CRATE}={}", rlib.display()),
"--out-dir".into(),
dir.display().to_string(),
probe.display().to_string(),
])
.collect()
}
const SUBJECT_CRATE: &str = "subject";
const PROBE_ITEM: &str = "pub fn __io_harness_probe() {}\n";
const PROBE_CRATE: &str = "extern crate subject;\n\
pub fn __io_harness_check() { subject::__io_harness_probe() }\n";
const CRITERION_OPEN: &str = "\n#[cfg(test)]
mod __io_harness_criterion {
#[allow(unused_imports)]
use ::core::{assert, assert_eq, assert_ne, debug_assert, debug_assert_eq, debug_assert_ne,
matches, panic, todo, unimplemented, unreachable, write, writeln, format_args};
#[allow(unused_imports)]
use ::std::{dbg, eprint, eprintln, format, print, println, vec};
#[allow(unused_imports)] use super::*;
";
const CRITERION_CLOSE: &str = "\n}\n";
async fn compile_source(
source: &str,
test_src: Option<&str>,
guard: &ExecGuard<'_>,
) -> Result<bool> {
let dir = tempfile::tempdir()?;
match test_src {
None => {
let subject = dir.path().join("subject.rs");
tokio::fs::write(&subject, format!("{source}\n{PROBE_ITEM}")).await?;
let rlib = dir.path().join("libsubject.rlib");
let args = subject_lib_args(&subject, &rlib);
guard.check("rustc", &args)?;
let argv = std::iter::once("rustc".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
if !guard.exec(&argv, dir.path()).await? {
guard.record_gate_failure("subject-compile");
return Ok(false);
}
let probe = dir.path().join("probe.rs");
tokio::fs::write(&probe, PROBE_CRATE).await?;
let args = probe_args(dir.path(), &probe, &rlib);
guard.check("rustc", &args)?;
let argv = std::iter::once("rustc".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
let passed = guard.exec(&argv, dir.path()).await?;
if !passed {
guard.record_gate_failure("subject-emptied");
}
Ok(passed)
}
Some(test) => {
let subject = dir.path().join("subject.rs");
tokio::fs::write(&subject, format!("{source}\n{PROBE_ITEM}")).await?;
let rlib = dir.path().join("libsubject.rlib");
let args = subject_lib_args(&subject, &rlib);
guard.check("rustc", &args)?;
let subject_argv = std::iter::once("rustc".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
if !guard.exec(&subject_argv, dir.path()).await? {
guard.record_gate_failure("subject-compile");
return Ok(false);
}
let probe = dir.path().join("probe.rs");
tokio::fs::write(&probe, PROBE_CRATE).await?;
let args = probe_args(dir.path(), &probe, &rlib);
guard.check("rustc", &args)?;
let probe_argv = std::iter::once("rustc".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
if !guard.exec(&probe_argv, dir.path()).await? {
guard.record_gate_failure("subject-emptied");
return Ok(false);
}
let combined = dir.path().join("combined.rs");
tokio::fs::write(
&combined,
format!("{source}\n{CRITERION_OPEN}{test}{CRITERION_CLOSE}"),
)
.await?;
let bin = dir.path().join("t");
let args = test_build_args(&combined, &bin);
guard.check("rustc", &args)?;
let build_argv = std::iter::once("rustc".to_string())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
if !guard.exec(&build_argv, dir.path()).await? {
guard.record_gate_failure("criterion-compile");
return Ok(false);
}
guard.check(TEST_BINARY, &[bin.display().to_string()])?;
let passed = guard.exec(&[bin.display().to_string()], dir.path()).await?;
if !passed {
guard.record_gate_failure("test-run");
}
Ok(passed)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
async fn passes(v: &Verification, contents: &str) -> bool {
v.passes(&PathBuf::from("unused"), contents).await.unwrap()
}
#[tokio::test]
async fn contains_passes_and_fails() {
let v = Verification::FileContains("fn hello".into());
assert!(passes(&v, "pub fn hello() {}").await);
assert!(!passes(&v, "pub fn world() {}").await);
}
#[tokio::test]
async fn equals_is_exact() {
let v = Verification::FileEquals("a".into());
assert!(passes(&v, "a").await);
assert!(!passes(&v, "a ").await);
}
#[tokio::test]
async fn compiles_rust_rejects_stub_accepts_real() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.rs");
tokio::fs::write(&file, "fn hello").await.unwrap();
assert!(!Verification::CompilesRust
.passes(&file, "fn hello")
.await
.unwrap());
let good = "pub fn hello() -> u32 { 42 }\n";
tokio::fs::write(&file, good).await.unwrap();
assert!(Verification::CompilesRust
.passes(&file, good)
.await
.unwrap());
}
#[tokio::test]
async fn rust_test_passes_only_when_test_passes() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.rs");
let good = "pub fn hello() -> u32 { 42 }\n";
tokio::fs::write(&file, good).await.unwrap();
let ok = Verification::RustTestPasses {
test_src: "#[test] fn t() { assert_eq!(hello(), 42); }".into(),
};
assert!(ok.passes(&file, good).await.unwrap());
let bad = Verification::RustTestPasses {
test_src: "#[test] fn t() { assert_eq!(hello(), 41); }".into(),
};
assert!(!bad.passes(&file, good).await.unwrap());
}
#[tokio::test]
async fn a_command_absent_from_the_allow_list_is_refused_not_failed() {
let policy = Policy::default().layer("locked").deny_exec("rustc");
let guard = ExecGuard::new(&policy);
let good = "pub fn hello() -> u32 { 42 }\n";
let refused = Verification::CompilesRust
.passes_guarded(Path::new("x.rs"), good, &guard)
.await;
assert!(
matches!(refused, Err(Error::Refused { ref target, .. }) if target == "rustc"),
"expected a typed refusal, got {refused:?}"
);
let allowed = Policy::default();
assert!(Verification::CompilesRust
.passes_guarded(Path::new("x.rs"), good, &ExecGuard::new(&allowed))
.await
.unwrap());
}
#[tokio::test]
async fn denying_the_test_binary_type_checks_without_running_it() {
let policy = Policy::default().layer("no-exec").deny_exec(TEST_BINARY);
let v = Verification::RustTestPasses {
test_src: "#[test] fn t() { assert_eq!(hello(), 42); }".into(),
};
let out = v
.passes_guarded(
Path::new("x.rs"),
"pub fn hello() -> u32 { 42 }\n",
&ExecGuard::new(&policy),
)
.await;
assert!(
matches!(out, Err(Error::Refused { ref target, .. }) if target == TEST_BINARY),
"expected the run of the test binary to be refused, got {out:?}"
);
}
#[tokio::test]
async fn a_verification_with_no_policy_still_spawns_as_0_3_0_did() {
assert!(Verification::CompilesRust
.passes(Path::new("x.rs"), "pub fn hello() -> u32 { 42 }\n")
.await
.unwrap());
}
#[tokio::test]
async fn each_compiles_rust_fails_if_any_file_fails() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.rs"), "pub fn a() -> u32 { 1 }\n").unwrap();
std::fs::write(root.join("b.rs"), "pub fn b() -> u32 { 2 }\n").unwrap();
let v = Verification::EachCompilesRust(vec!["a.rs".into(), "b.rs".into()]);
assert!(v.passes_in(root).await.unwrap());
std::fs::write(root.join("b.rs"), "pub fn b").unwrap();
assert!(!v.passes_in(root).await.unwrap());
}
#[tokio::test]
async fn workspace_test_passes_only_when_files_work_together() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::write(root.join("a.rs"), "pub fn a() -> u32 { 40 }\n").unwrap();
std::fs::write(root.join("b.rs"), "pub fn b() -> u32 { 2 }\n").unwrap();
let v = Verification::WorkspaceTestPasses {
files: vec!["a.rs".into(), "b.rs".into()],
test_src: "#[test] fn t() { assert_eq!(a() + b(), 42); }".into(),
};
assert!(v.passes_in(root).await.unwrap());
std::fs::write(root.join("b.rs"), "pub fn b() -> u32 { 99 }\n").unwrap();
assert!(!v.passes_in(root).await.unwrap());
}
#[tokio::test]
async fn multi_file_variant_errors_in_single_file_mode() {
let v = Verification::EachCompilesRust(vec!["a.rs".into()]);
assert!(v.passes(&PathBuf::from("unused"), "").await.is_err());
}
#[cfg(feature = "docx")]
#[tokio::test]
async fn a_document_criterion_passes_on_text_the_document_actually_shows() {
let dir = tempfile::tempdir().unwrap();
let ws = crate::tools::Workspace::new(dir.path());
crate::tools::documents::docx::write_new(
&ws,
"report.docx",
&["Quarterly revenue rose".to_string()],
)
.unwrap();
let v = Verification::DocumentContains {
file: "report.docx".into(),
needle: "revenue rose".into(),
};
assert!(v.passes_in(dir.path()).await.unwrap());
}
#[cfg(feature = "docx")]
#[tokio::test]
async fn a_document_criterion_fails_on_text_the_document_does_not_show() {
let dir = tempfile::tempdir().unwrap();
let ws = crate::tools::Workspace::new(dir.path());
crate::tools::documents::docx::write_new(&ws, "report.docx", &["Nothing here".to_string()])
.unwrap();
let v = Verification::DocumentContains {
file: "report.docx".into(),
needle: "revenue rose".into(),
};
assert!(!v.passes_in(dir.path()).await.unwrap());
}
#[cfg(feature = "docx")]
#[tokio::test]
async fn the_byte_criterion_cannot_read_a_document_and_this_one_can() {
let dir = tempfile::tempdir().unwrap();
let ws = crate::tools::Workspace::new(dir.path());
crate::tools::documents::docx::write_new(
&ws,
"report.docx",
&["Quarterly revenue rose".to_string()],
)
.unwrap();
let needle = "revenue rose";
let byte_match = Verification::WorkspaceFileContains {
file: "report.docx".into(),
needle: needle.into(),
};
assert!(
!byte_match.passes_in(dir.path()).await.unwrap(),
"the byte criterion reads a document as empty and can never pass — \
this is the wrong answer the variant exists to fix"
);
let text_match = Verification::DocumentContains {
file: "report.docx".into(),
needle: needle.into(),
};
assert!(
text_match.passes_in(dir.path()).await.unwrap(),
"the document criterion reads what the document shows"
);
let container = "word/document.xml";
let raw = std::fs::read(dir.path().join("report.docx")).unwrap();
assert!(
String::from_utf8_lossy(&raw).contains(container),
"the fixture must carry the entry name in its bytes, or the next \
assertion proves nothing"
);
let container_match = Verification::DocumentContains {
file: "report.docx".into(),
needle: container.into(),
};
assert!(
!container_match.passes_in(dir.path()).await.unwrap(),
"a needle only in the container must not match the text"
);
}
#[tokio::test]
async fn a_document_criterion_refuses_a_format_it_cannot_read() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("notes.txt"), "revenue rose").unwrap();
let v = Verification::DocumentContains {
file: "notes.txt".into(),
needle: "revenue rose".into(),
};
let err = v.passes_in(dir.path()).await.unwrap_err();
assert!(
err.to_string().contains("txt"),
"the error names the extension it will not guess at, got {err}"
);
}
#[tokio::test]
async fn a_document_criterion_needs_a_workspace_root() {
let v = Verification::DocumentContains {
file: "report.docx".into(),
needle: "x".into(),
};
let err = v
.passes(std::path::Path::new("f.rs"), "irrelevant")
.await
.unwrap_err();
assert!(err.to_string().contains("workspace root"), "got {err}");
}
}