1use std::path::{Path, PathBuf};
44use std::time::Duration;
45
46use async_trait::async_trait;
47use boundgraph_core::{CheckerId, Condensate, Digest, GrantSet, WorkOrder};
48
49use crate::checker::{Checker, Judgement};
50use crate::sandbox::{Command, DEFAULT_TIMEOUT, Sandbox, SandboxError, workspace_grant};
51
52pub const MAX_REASON: usize = 4_000;
58
59#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CompileConfig {
65 pub program: String,
67 pub args: Vec<String>,
69 pub version_args: Vec<String>,
71 pub workspace: PathBuf,
73 pub timeout: Duration,
75}
76
77impl CompileConfig {
78 pub fn new(
81 program: impl Into<String>,
82 args: impl IntoIterator<Item = impl Into<String>>,
83 workspace: impl Into<PathBuf>,
84 ) -> Self {
85 Self {
86 program: program.into(),
87 args: args.into_iter().map(Into::into).collect(),
88 version_args: vec!["--version".to_string()],
89 workspace: workspace.into(),
90 timeout: DEFAULT_TIMEOUT,
91 }
92 }
93
94 pub fn cargo(workspace: impl Into<PathBuf>) -> Self {
99 Self::new("cargo", ["check", "--all-targets", "--quiet"], workspace)
100 }
101
102 pub fn tsc(workspace: impl Into<PathBuf>) -> Self {
107 Self::new("tsc", ["--noEmit"], workspace)
108 }
109
110 pub fn mypy(workspace: impl Into<PathBuf>) -> Self {
112 Self::new("mypy", ["."], workspace)
113 }
114
115 #[must_use]
117 pub fn versioned_by(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
118 self.version_args = args.into_iter().map(Into::into).collect();
119 self
120 }
121
122 #[must_use]
124 pub fn within(mut self, timeout: Duration) -> Self {
125 self.timeout = timeout;
126 self
127 }
128
129 pub fn grant(&self) -> GrantSet {
132 workspace_grant(&self.workspace, &self.program)
133 }
134
135 fn check_command(&self) -> Command {
136 Command::new(&self.program, self.args.clone(), self.workspace.clone()).within(self.timeout)
137 }
138
139 fn version_command(&self) -> Command {
140 Command::new(
141 &self.program,
142 self.version_args.clone(),
143 self.workspace.clone(),
144 )
145 .within(Duration::from_secs(30))
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
152#[non_exhaustive]
153pub enum ProbeError {
154 #[error("could not run `{command}` to identify the tool: {source}")]
156 Unrunnable {
157 command: String,
159 #[source]
161 source: SandboxError,
162 },
163
164 #[error("`{command}` did not report a version: {detail}")]
166 NoVersion {
167 command: String,
169 detail: String,
171 },
172}
173
174#[derive(Debug, Clone)]
179pub struct CompileChecker {
180 config: CompileConfig,
181 tool: String,
182 id: CheckerId,
183}
184
185impl CompileChecker {
186 pub async fn probe(sandbox: &dyn Sandbox, config: CompileConfig) -> Result<Self, ProbeError> {
193 let command = config.version_command();
194 let outcome = sandbox
195 .run(&command, &config.grant())
196 .await
197 .map_err(|source| ProbeError::Unrunnable {
198 command: command.rendered(),
199 source,
200 })?;
201
202 if !outcome.succeeded() {
203 return Err(ProbeError::NoVersion {
204 command: command.rendered(),
205 detail: if outcome.timed_out {
206 "it did not finish".to_string()
207 } else {
208 format!("exit {:?}: {}", outcome.exit, outcome.diagnostics())
209 },
210 });
211 }
212
213 let tool = outcome
218 .stdout
219 .lines()
220 .map(str::trim)
221 .find(|line| !line.is_empty())
222 .ok_or_else(|| ProbeError::NoVersion {
223 command: command.rendered(),
224 detail: "it printed nothing".to_string(),
225 })?
226 .to_string();
227
228 Ok(Self::declaring(config, tool))
229 }
230
231 pub fn declaring(config: CompileConfig, tool: impl Into<String>) -> Self {
238 let tool = tool.into();
239 Self {
240 id: CheckerId::new(format!("compile[{tool}]"), 1),
243 tool,
244 config,
245 }
246 }
247
248 pub fn tool(&self) -> &str {
250 &self.tool
251 }
252
253 pub fn workspace(&self) -> &Path {
255 &self.config.workspace
256 }
257}
258
259pub fn head(text: &str, limit: usize) -> String {
264 if text.chars().count() <= limit {
265 return text.to_string();
266 }
267 let kept: String = text.chars().take(limit).collect();
268 format!("{kept}\n… (output truncated)")
269}
270
271#[derive(Debug)]
277pub struct SandboxedCompile<S: Sandbox> {
278 checker: CompileChecker,
279 sandbox: S,
280}
281
282impl<S: Sandbox> SandboxedCompile<S> {
283 pub async fn probe(sandbox: S, config: CompileConfig) -> Result<Self, ProbeError> {
289 let checker = CompileChecker::probe(&sandbox, config).await?;
290 Ok(Self { checker, sandbox })
291 }
292
293 pub fn declaring(sandbox: S, config: CompileConfig, tool: impl Into<String>) -> Self {
295 Self {
296 checker: CompileChecker::declaring(config, tool),
297 sandbox,
298 }
299 }
300
301 pub fn tool(&self) -> &str {
303 self.checker.tool()
304 }
305}
306
307#[async_trait]
308impl<S: Sandbox> Checker for SandboxedCompile<S> {
309 fn id(&self) -> CheckerId {
310 self.checker.id.clone()
311 }
312
313 fn grants(&self) -> GrantSet {
314 self.checker.config.grant()
315 }
316
317 async fn judge(&self, _order: &WorkOrder, _condensate: &Condensate) -> Judgement {
318 let config = &self.checker.config;
319 let command = config.check_command();
320
321 let tool = Digest::of(self.checker.tool.as_bytes());
325
326 let outcome = match self.sandbox.run(&command, &config.grant()).await {
327 Ok(outcome) => outcome,
328 Err(e) => {
331 return Judgement::inconclusive(format!("the check could not be run: {e}"))
332 .covering(tool);
333 }
334 };
335
336 if outcome.timed_out {
337 return Judgement::inconclusive(format!(
338 "`{}` did not finish within {:?}",
339 command.rendered(),
340 config.timeout
341 ))
342 .covering(tool);
343 }
344
345 let judgement = match outcome.exit {
346 Some(0) => Judgement::pass(),
347 Some(code) => {
348 let diagnostics = outcome.diagnostics();
349 let detail = if diagnostics.is_empty() {
350 "no diagnostics were produced".to_string()
351 } else {
352 head(&diagnostics, MAX_REASON)
353 };
354 Judgement::fail(format!(
355 "`{}` exited {code} under {}:\n{detail}",
356 command.rendered(),
357 self.checker.tool
358 ))
359 }
360 None => Judgement::inconclusive(format!(
363 "`{}` ended without an exit status",
364 command.rendered()
365 )),
366 };
367
368 judgement.covering(tool)
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::sandbox::Outcome;
376 use crate::sign::LocalKey;
377 use boundgraph_core::{CondensatePayload, Intent, ProposalOrigin, SchemaId, SlicerId, Verdict};
378 use std::sync::Mutex;
379
380 #[derive(Debug)]
382 struct Scripted {
383 version: Result<Outcome, SandboxError>,
384 check: Result<Outcome, SandboxError>,
385 seen: Mutex<Vec<String>>,
386 }
387
388 impl Scripted {
389 fn new(version: Outcome, check: Result<Outcome, SandboxError>) -> Self {
390 Self {
391 version: Ok(version),
392 check,
393 seen: Mutex::new(Vec::new()),
394 }
395 }
396
397 fn ran(&self) -> Vec<String> {
398 self.seen.lock().unwrap().clone()
399 }
400 }
401
402 #[async_trait]
403 impl Sandbox for Scripted {
404 async fn run(&self, command: &Command, _grant: &GrantSet) -> Result<Outcome, SandboxError> {
405 self.seen.lock().unwrap().push(command.rendered());
406 if command.args.iter().any(|a| a == "--version") {
407 self.version.clone()
408 } else {
409 self.check.clone()
410 }
411 }
412 }
413
414 fn outcome(exit: Option<i32>, stdout: &str, stderr: &str) -> Outcome {
415 Outcome {
416 exit,
417 stdout: stdout.to_string(),
418 stderr: stderr.to_string(),
419 timed_out: false,
420 truncated: false,
421 duration: Duration::from_millis(10),
422 }
423 }
424
425 fn timed_out() -> Outcome {
426 Outcome {
427 exit: None,
428 stdout: String::new(),
429 stderr: String::new(),
430 timed_out: true,
431 truncated: false,
432 duration: Duration::from_secs(300),
433 }
434 }
435
436 fn version() -> Outcome {
437 outcome(Some(0), "cargo 1.85.0 (1d8b05cdd 2026-01-20)\n", "")
438 }
439
440 fn config() -> CompileConfig {
441 CompileConfig::cargo("/work/repo")
442 }
443
444 fn order() -> WorkOrder {
445 WorkOrder::new(
446 Intent::new("patch.apply", &"fix the borrow error").unwrap(),
447 vec![],
448 GrantSet::EMPTY,
449 ProposalOrigin::new("claude-opus-5", [], Digest::of(b"prompt")),
450 )
451 }
452
453 fn condensate() -> Condensate {
454 Condensate::new(
455 SchemaId::new("boundgraph.diff.v1", 1),
456 CondensatePayload::inline(&"one file changed").unwrap(),
457 Digest::of(b"the patch"),
458 400,
459 false,
460 SlicerId::new("diff", 1),
461 )
462 }
463
464 fn key() -> LocalKey {
465 LocalKey::from_seed("host-key-1", [7u8; 32])
466 }
467
468 async fn verdict_for(check: Result<Outcome, SandboxError>) -> Verdict {
469 let sandbox = Scripted::new(version(), check);
470 let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
471 checker
472 .check(&order(), &condensate(), &key())
473 .await
474 .unwrap()
475 .verdict
476 }
477
478 #[tokio::test]
482 async fn a_clean_build_passes() {
483 assert_eq!(
484 verdict_for(Ok(outcome(Some(0), "", ""))).await,
485 Verdict::Pass
486 );
487 }
488
489 #[tokio::test]
492 async fn a_failed_build_fails_with_the_diagnostics() {
493 let errors = "error[E0502]: cannot borrow `x` as mutable\n --> src/main.rs:12:5\n";
494 let verdict = verdict_for(Ok(outcome(Some(101), "", errors))).await;
495
496 let Verdict::Fail(reason) = verdict else {
497 panic!("expected Fail, got {verdict:?}");
498 };
499 assert!(reason.contains("E0502"), "{reason}");
500 assert!(reason.contains("src/main.rs:12"), "{reason}");
501 assert!(reason.contains("exited 101"), "{reason}");
502 }
503
504 #[tokio::test]
506 async fn a_timeout_is_inconclusive_never_a_pass() {
507 let verdict = verdict_for(Ok(timed_out())).await;
508
509 assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
510 assert!(!verdict.is_pass());
511 }
512
513 #[tokio::test]
516 async fn a_timeout_is_not_a_failure_either() {
517 assert!(!matches!(
518 verdict_for(Ok(timed_out())).await,
519 Verdict::Fail(_)
520 ));
521 }
522
523 #[tokio::test]
524 async fn a_sandbox_that_refuses_leaves_the_verdict_undecided() {
525 let refused = Err(SandboxError::Unenforceable {
526 atom: "net:egress:**".into(),
527 command: "cargo check".into(),
528 });
529 let verdict = verdict_for(refused).await;
530
531 assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
532 }
533
534 #[tokio::test]
535 async fn a_tool_killed_by_a_signal_is_inconclusive() {
536 let verdict = verdict_for(Ok(outcome(None, "", ""))).await;
537 assert!(matches!(verdict, Verdict::Inconclusive(_)), "{verdict:?}");
538 }
539
540 #[tokio::test]
541 async fn a_failure_with_no_output_at_all_still_says_something_useful() {
542 let verdict = verdict_for(Ok(outcome(Some(1), "", ""))).await;
543
544 let Verdict::Fail(reason) = verdict else {
545 panic!("expected Fail");
546 };
547 assert!(reason.contains("no diagnostics"), "{reason}");
548 }
549
550 #[tokio::test]
551 async fn diagnostics_on_stdout_are_read_too() {
552 let verdict = verdict_for(Ok(outcome(
553 Some(2),
554 "error: Type 'x' is not assignable",
555 "",
556 )))
557 .await;
558 assert!(verdict.reason().unwrap().contains("not assignable"));
559 }
560
561 #[tokio::test]
565 async fn the_tool_version_reaches_the_evidence() {
566 let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
567 let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
568
569 let evidence = checker
570 .check(&order(), &condensate(), &key())
571 .await
572 .unwrap();
573
574 assert!(
575 evidence.checker.name.contains("cargo 1.85.0"),
576 "an auditor must be able to read which compiler said so: {}",
577 evidence.checker.name
578 );
579 assert!(
580 evidence.covers(&Digest::of(b"cargo 1.85.0 (1d8b05cdd 2026-01-20)")),
581 "and it must be bound by the signature, not merely printed"
582 );
583 }
584
585 #[tokio::test]
586 async fn two_versions_of_one_tool_are_different_checkers() {
587 let a = SandboxedCompile::declaring(
588 Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
589 config(),
590 "tsc 5.4.2",
591 );
592 let b = SandboxedCompile::declaring(
593 Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
594 config(),
595 "tsc 5.7.0",
596 );
597
598 assert_ne!(a.id(), b.id());
599 }
600
601 #[tokio::test]
604 async fn a_tool_that_will_not_say_what_it_is_yields_no_checker() {
605 let silent = Scripted::new(outcome(Some(0), "", ""), Ok(outcome(Some(0), "", "")));
606 let err = SandboxedCompile::probe(silent, config()).await.unwrap_err();
607 assert!(matches!(err, ProbeError::NoVersion { .. }), "{err:?}");
608
609 let missing = Scripted {
610 version: Err(SandboxError::Spawn {
611 command: "tsc --version".into(),
612 detail: "not found".into(),
613 }),
614 check: Ok(outcome(Some(0), "", "")),
615 seen: Mutex::new(Vec::new()),
616 };
617 let err = SandboxedCompile::probe(missing, config())
618 .await
619 .unwrap_err();
620 assert!(matches!(err, ProbeError::Unrunnable { .. }), "{err:?}");
621 }
622
623 #[tokio::test]
624 async fn a_version_command_that_fails_yields_no_checker() {
625 let broken = Scripted::new(
626 outcome(Some(127), "", "command not found"),
627 Ok(outcome(Some(0), "", "")),
628 );
629 assert!(SandboxedCompile::probe(broken, config()).await.is_err());
630 }
631
632 #[tokio::test]
633 async fn the_version_is_asked_for_before_the_check_is_run() {
634 let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
635 let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
636 checker
637 .check(&order(), &condensate(), &key())
638 .await
639 .unwrap();
640
641 let ran = checker.sandbox.ran();
642 assert!(ran[0].contains("--version"), "{ran:?}");
643 assert!(ran[1].contains("check"), "{ran:?}");
644 }
645
646 #[tokio::test]
651 async fn the_grant_reads_the_workspace_and_nothing_else() {
652 let checker = SandboxedCompile::declaring(
653 Scripted::new(version(), Ok(outcome(Some(0), "", ""))),
654 config(),
655 "cargo 1.85.0",
656 );
657 let grant = checker.grants();
658
659 assert!(grant.iter().all(|a| a.action != "write"), "{grant:?}");
660 assert!(grant.iter().all(|a| a.domain != "net"), "{grant:?}");
661 assert!(grant.iter().all(|a| a.domain != "secret"), "{grant:?}");
662 assert!(
663 grant
664 .iter()
665 .filter(|a| a.domain == "fs")
666 .all(|a| a.resource.starts_with("/work/repo/")),
667 "{grant:?}"
668 );
669 assert!(
670 grant
671 .iter()
672 .any(|a| a.domain == "proc" && a.resource == "cargo")
673 );
674 }
675
676 #[tokio::test]
678 async fn the_grant_is_within_what_a_checker_may_hold() {
679 use boundgraph_grant::{Tri, set_leq};
680 assert_eq!(
681 set_leq(&config().grant(), &crate::checker_ceiling()),
682 Tri::Yes
683 );
684 }
685
686 #[tokio::test]
687 async fn tsc_is_told_not_to_emit_because_a_checker_may_not_write() {
688 assert!(
689 CompileConfig::tsc("/w")
690 .args
691 .iter()
692 .any(|a| a == "--noEmit")
693 );
694 }
695
696 #[tokio::test]
699 async fn a_huge_diagnostic_is_cut_from_the_front_where_the_real_error_is() {
700 let noise = format!(
701 "error[E0001]: the first and only real problem\n{}",
702 "error: knock-on failure\n".repeat(5_000)
703 );
704 let verdict = verdict_for(Ok(outcome(Some(101), "", &noise))).await;
705 let reason = verdict.reason().unwrap();
706
707 assert!(reason.contains("E0001"), "the cause must survive");
708 assert!(reason.contains("truncated"), "and say it was cut");
709 assert!(reason.chars().count() < MAX_REASON + 500);
710 }
711
712 #[test]
713 fn head_keeps_the_start_and_says_when_it_cut() {
714 let text = "first\nmiddle\nlast";
715 assert!(head(text, 6).starts_with("first"));
716 assert!(head(text, 6).contains("truncated"));
717
718 assert_eq!(head(text, 100), text);
720 }
721
722 #[test]
723 fn truncating_does_not_cut_a_character_in_half() {
724 let text = "é".repeat(100);
725 assert!(!head(&text, 10).contains('\u{fffd}'));
726 }
727
728 #[tokio::test]
731 async fn the_same_build_result_always_gives_the_same_evidence() {
732 let sandbox = Scripted::new(version(), Ok(outcome(Some(0), "", "")));
733 let checker = SandboxedCompile::probe(sandbox, config()).await.unwrap();
734 let (order, k, key) = (order(), condensate(), key());
735
736 let first = checker.check(&order, &k, &key).await.unwrap();
737 for _ in 0..4 {
738 assert_eq!(checker.check(&order, &k, &key).await.unwrap(), first);
739 }
740 }
741}