use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use boundgraph_core::{CheckerId, Condensate, Digest, GrantSet, WorkOrder};
use crate::checker::{Checker, Judgement};
use crate::sandbox::{Command, DEFAULT_TIMEOUT, Sandbox, SandboxError, workspace_grant};
pub const MAX_REASON: usize = 4_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompileConfig {
pub program: String,
pub args: Vec<String>,
pub version_args: Vec<String>,
pub workspace: PathBuf,
pub timeout: Duration,
}
impl CompileConfig {
pub fn new(
program: impl Into<String>,
args: impl IntoIterator<Item = impl Into<String>>,
workspace: impl Into<PathBuf>,
) -> Self {
Self {
program: program.into(),
args: args.into_iter().map(Into::into).collect(),
version_args: vec!["--version".to_string()],
workspace: workspace.into(),
timeout: DEFAULT_TIMEOUT,
}
}
pub fn cargo(workspace: impl Into<PathBuf>) -> Self {
Self::new("cargo", ["check", "--all-targets", "--quiet"], workspace)
}
pub fn tsc(workspace: impl Into<PathBuf>) -> Self {
Self::new("tsc", ["--noEmit"], workspace)
}
pub fn mypy(workspace: impl Into<PathBuf>) -> Self {
Self::new("mypy", ["."], workspace)
}
#[must_use]
pub fn versioned_by(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.version_args = args.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn within(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn grant(&self) -> GrantSet {
workspace_grant(&self.workspace, &self.program)
}
fn check_command(&self) -> Command {
Command::new(&self.program, self.args.clone(), self.workspace.clone()).within(self.timeout)
}
fn version_command(&self) -> Command {
Command::new(
&self.program,
self.version_args.clone(),
self.workspace.clone(),
)
.within(Duration::from_secs(30))
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ProbeError {
#[error("could not run `{command}` to identify the tool: {source}")]
Unrunnable {
command: String,
#[source]
source: SandboxError,
},
#[error("`{command}` did not report a version: {detail}")]
NoVersion {
command: String,
detail: String,
},
}
#[derive(Debug, Clone)]
pub struct CompileChecker {
config: CompileConfig,
tool: String,
id: CheckerId,
}
impl CompileChecker {
pub async fn probe(sandbox: &dyn Sandbox, config: CompileConfig) -> Result<Self, ProbeError> {
let command = config.version_command();
let outcome = sandbox
.run(&command, &config.grant())
.await
.map_err(|source| ProbeError::Unrunnable {
command: command.rendered(),
source,
})?;
if !outcome.succeeded() {
return Err(ProbeError::NoVersion {
command: command.rendered(),
detail: if outcome.timed_out {
"it did not finish".to_string()
} else {
format!("exit {:?}: {}", outcome.exit, outcome.diagnostics())
},
});
}
let tool = outcome
.stdout
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.ok_or_else(|| ProbeError::NoVersion {
command: command.rendered(),
detail: "it printed nothing".to_string(),
})?
.to_string();
Ok(Self::declaring(config, tool))
}
pub fn declaring(config: CompileConfig, tool: impl Into<String>) -> Self {
let tool = tool.into();
Self {
id: CheckerId::new(format!("compile[{tool}]"), 1),
tool,
config,
}
}
pub fn tool(&self) -> &str {
&self.tool
}
pub fn workspace(&self) -> &Path {
&self.config.workspace
}
}
pub fn head(text: &str, limit: usize) -> String {
if text.chars().count() <= limit {
return text.to_string();
}
let kept: String = text.chars().take(limit).collect();
format!("{kept}\n… (output truncated)")
}
#[derive(Debug)]
pub struct SandboxedCompile<S: Sandbox> {
checker: CompileChecker,
sandbox: S,
}
impl<S: Sandbox> SandboxedCompile<S> {
pub async fn probe(sandbox: S, config: CompileConfig) -> Result<Self, ProbeError> {
let checker = CompileChecker::probe(&sandbox, config).await?;
Ok(Self { checker, sandbox })
}
pub fn declaring(sandbox: S, config: CompileConfig, tool: impl Into<String>) -> Self {
Self {
checker: CompileChecker::declaring(config, tool),
sandbox,
}
}
pub fn tool(&self) -> &str {
self.checker.tool()
}
}
#[async_trait]
impl<S: Sandbox> Checker for SandboxedCompile<S> {
fn id(&self) -> CheckerId {
self.checker.id.clone()
}
fn grants(&self) -> GrantSet {
self.checker.config.grant()
}
async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
let config = &self.checker.config;
let command = config.check_command();
let tool = Digest::of(self.checker.tool.as_bytes());
let outcome = match self.sandbox.run(&command, &config.grant()).await {
Ok(outcome) => outcome,
Err(e) => {
return Judgement::inconclusive(format!("the check could not be run: {e}"))
.covering(tool);
}
};
if outcome.timed_out {
return Judgement::inconclusive(format!(
"`{}` did not finish within {:?}",
command.rendered(),
config.timeout
))
.covering(tool);
}
let judgement = match outcome.exit {
Some(0) => Judgement::pass(),
Some(code) => {
let diagnostics = outcome.diagnostics();
let detail = if diagnostics.is_empty() {
"no diagnostics were produced".to_string()
} else {
head(&diagnostics, MAX_REASON)
};
Judgement::fail(format!(
"`{}` exited {code} under {}:\n{detail}",
command.rendered(),
self.checker.tool
))
}
None => Judgement::inconclusive(format!(
"`{}` ended without an exit status",
command.rendered()
)),
};
judgement.covering(tool)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sandbox::Outcome;
use crate::sign::LocalKey;
use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
use std::sync::Mutex;
#[derive(Debug)]
struct Scripted {
version: Result<Outcome, SandboxError>,
check: Result<Outcome, SandboxError>,
seen: Mutex<Vec<String>>,
}
impl Scripted {
fn new(version: Outcome, check: Result<Outcome, SandboxError>) -> Self {
Self {
version: Ok(version),
check,
seen: Mutex::new(Vec::new()),
}
}
fn ran(&self) -> Vec<String> {
self.seen.lock().unwrap().clone()
}
}
#[async_trait]
impl Sandbox for Scripted {
async fn run(&self, command: &Command, _grant: &GrantSet) -> Result<Outcome, SandboxError> {
self.seen.lock().unwrap().push(command.rendered());
if command.args.iter().any(|a| a == "--version") {
self.version.clone()
} else {
self.check.clone()
}
}
}
fn outcome(exit: Option<i32>, stdout: &str, stderr: &str) -> Outcome {
Outcome {
exit,
stdout: stdout.to_string(),
stderr: stderr.to_string(),
timed_out: false,
truncated: false,
duration: Duration::from_millis(10),
}
}
fn timed_out() -> Outcome {
Outcome {
exit: None,
stdout: String::new(),
stderr: String::new(),
timed_out: true,
truncated: false,
duration: Duration::from_secs(300),
}
}
fn version() -> Outcome {
outcome(Some(0), "cargo 1.85.0 (1d8b05cdd 2026-01-20)\n", "")
}
fn config() -> CompileConfig {
CompileConfig::cargo("/work/repo")
}
fn order() -> WorkOrder {
WorkOrder::new(
Intent::new("patch.apply", &"fix the borrow error").unwrap(),
vec![],
GrantSet::EMPTY,
ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
)
}
fn condensate() -> Condensate {
Condensate::new(
SchemaId::new("boundgraph.diff.v1", 1),
CondensatePayload::inline(&"one file changed").unwrap(),
Digest::of(b"the patch"),
400,
false,
SlicerId::new("diff", 1),
)
}
fn key() -> LocalKey {
LocalKey::from_seed("host-key-1", [7u8; 32])
}
async fn verdict_for(check: Result<Outcome, SandboxError>) -> Verdict {
let sandbox = Scripted::new(version(), check);
let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
checker
.check(&order(), &condensate(), &key())
.await
.unwrap()
.verdict
}
#[tokio::test]
async fn a_clean_build_passes() {
assert_eq!(
verdict_for(Ok(outcome(Some(0), "", ""))).await,
Verdict::Pass
);
}
#[tokio::test]
async fn a_failed_build_fails_with_the_diagnostics() {
let errors = "error[E0502]: cannot borrow `x` as mutable\n --> src/main.rs:12:5\n";
let verdict = verdict_for(Ok(outcome(Some(101), "", errors))).await;
let Verdict::Fail(reason) = verdict else {
panic!("expected Fail, got {verdict:?}");
};
assert!(reason.contains("E0502"), "{reason}");
assert!(reason.contains("src/main.rs:12"), "{reason}");
assert!(reason.contains("exited 101"), "{reason}");
}
#[tokio::test]
async fn a_timeout_is_inconclusive_never_a_pass() {
let verdict = verdict_for(Ok(timed_out())).await;
assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
assert!(!verdict.is_pass());
}
#[tokio::test]
async fn a_timeout_is_not_a_failure_either() {
assert!(!matches!(
verdict_for(Ok(timed_out())).await,
Verdict::Fail(_)
));
}
#[tokio::test]
async fn a_sandbox_that_refuses_leaves_the_verdict_undecided() {
let refused = Err(SandboxError::Unenforceable {
atom: "net:egress:**".into(),
command: "cargo check".into(),
});
let verdict = verdict_for(refused).await;
assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
}
#[tokio::test]
async fn a_tool_killed_by_a_signal_is_inconclusive() {
let verdict = verdict_for(Ok(outcome(None, "", ""))).await;
assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
}
#[tokio::test]
async fn a_failure_with_no_output_at_all_still_says_something_useful() {
let verdict = verdict_for(Ok(outcome(Some(1), "", ""))).await;
let Verdict::Fail(reason) = verdict else {
panic!("expected Fail");
};
assert!(reason.contains("no diagnostics"), "{reason}");
}
#[tokio::test]
async fn diagnostics_on_stdout_are_read_too() {
let verdict = verdict_for(Ok(outcome(
Some(2),
"error: Type 'x' is not assignable",
"",
)))
.await;
assert!(verdict.reason().unwrap().contains("not assignable"));
}
#[tokio::test]
async fn the_tool_version_reaches_the_evidence() {
let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
let evidence = checker
.check(&order(), &condensate(), &key())
.await
.unwrap();
assert!(
evidence.checker.name.contains("cargo 1.85.0"),
"an auditor must be able to read which compiler said so: {}",
evidence.checker.name
);
assert!(
evidence.covers(&Digest::of(b"cargo 1.85.0 (1d8b05cdd 2026-01-20)")),
"and it must be bound by the signature, not merely printed"
);
}
#[tokio::test]
async fn two_versions_of_one_tool_are_different_checkers() {
let a = SandboxedCompile::declaring(
Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
config(),
"tsc 5.4.2",
);
let b = SandboxedCompile::declaring(
Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
config(),
"tsc 5.7.0",
);
assert_ne!(a.id(), b.id());
}
#[tokio::test]
async fn a_tool_that_will_not_say_what_it_is_yields_no_checker() {
let silent = Scripted::new(outcome(Some(0), "", ""), Ok(outcome(Some(0), "", "")));
let err = SandboxedCompile::probe(silent, config()).await.unwrap_err();
assert!(matches!(err, ProbeError::NoVersion { .. }), "{err:?}");
let missing = Scripted {
version: Err(SandboxError::Spawn {
command: "tsc --version".into(),
detail: "not found".into(),
}),
check: Ok(outcome(Some(0), "", "")),
seen: Mutex::new(Vec::new()),
};
let err = SandboxedCompile::probe(missing, config())
.await
.unwrap_err();
assert!(matches!(err, ProbeError::Unrunnable { .. }), "{err:?}");
}
#[tokio::test]
async fn a_version_command_that_fails_yields_no_checker() {
let broken = Scripted::new(
outcome(Some(127), "", "command not found"),
Ok(outcome(Some(0), "", "")),
);
assert!(SandboxedCompile::probe(broken, config()).await.is_err());
}
#[tokio::test]
async fn the_version_is_asked_for_before_the_check_is_run() {
let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
checker
.check(&order(), &condensate(), &key())
.await
.unwrap();
let ran = checker.sandbox.ran();
assert!(ran[0].contains("--version"), "{ran:?}");
assert!(ran[1].contains("check"), "{ran:?}");
}
#[tokio::test]
async fn the_grant_reads_the_workspace_and_nothing_else() {
let checker = SandboxedCompile::declaring(
Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
config(),
"cargo 1.85.0",
);
let grant = checker.grants();
assert!(grant.iter().all(|a| a.action != "write"), "{grant:?}");
assert!(grant.iter().all(|a| a.domain != "net"), "{grant:?}");
assert!(grant.iter().all(|a| a.domain != "secret"), "{grant:?}");
assert!(
grant
.iter()
.filter(|a| a.domain == "fs")
.all(|a| a.resource.starts_with("/work/repo/")),
"{grant:?}"
);
assert!(
grant
.iter()
.any(|a| a.domain == "proc" && a.resource == "cargo")
);
}
#[tokio::test]
async fn the_grant_is_within_what_a_checker_may_hold() {
use boundgraph_grant::{Tri, set_leq};
assert_eq!(
set_leq(&config().grant(), &crate::checker_ceiling()),
Tri::Yes
);
}
#[tokio::test]
async fn tsc_is_told_not_to_emit_because_a_checker_may_not_write() {
assert!(
CompileConfig::tsc("/w")
.args
.iter()
.any(|a| a == "--noEmit")
);
}
#[tokio::test]
async fn a_huge_diagnostic_is_cut_from_the_front_where_the_real_error_is() {
let noise = format!(
"error[E0001]: the first and only real problem\n{}",
"error: knock-on failure\n".repeat(5_000)
);
let verdict = verdict_for(Ok(outcome(Some(101), "", &noise))).await;
let reason = verdict.reason().unwrap();
assert!(reason.contains("E0001"), "the cause must survive");
assert!(reason.contains("truncated"), "and say it was cut");
assert!(reason.chars().count() < MAX_REASON + 500);
}
#[test]
fn head_keeps_the_start_and_says_when_it_cut() {
let text = "first\nmiddle\nlast";
assert!(head(text, 6).starts_with("first"));
assert!(head(text, 6).contains("truncated"));
assert_eq!(head(text, 100), text);
}
#[test]
fn truncating_does_not_cut_a_character_in_half() {
let text = "é".repeat(100);
assert!(!head(&text, 10).contains('\u{fffd}'));
}
#[tokio::test]
async fn the_same_build_result_always_gives_the_same_evidence() {
let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
let (order, k, key) = (order(), condensate(), key());
let first = checker.check(&order, &k, &key).await.unwrap();
for _ in 0..4 {
assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
}
}
}