io_harness/verify.rs
1//! The verification layer: checks that confirm the file meets the spec.
2//!
3//! Two kinds live here. Deterministic content checks ([`Verification::FileContains`],
4//! [`Verification::FileEquals`]) are cheap and cannot lie about what the file
5//! *says* — but they cannot confirm the file *works*. The 0.1.0 live run proved
6//! this: the model passed `FileContains("fn hello")` by writing the literal
7//! string `fn hello`, which does not compile (see
8//! `.ultraship/.../iterations/US-IO-HARNESS-0.2.0-I01`).
9//!
10//! v0.2 adds execution-based checks ([`Verification::CompilesRust`],
11//! [`Verification::RustTestPasses`]) that compile — and optionally test — the
12//! produced file with `rustc`. A substring stub fails to compile, so it fails
13//! the gate. Compilation happens in a throwaway temp dir that is removed
14//! afterwards, and `rustc` touches no network.
15//!
16//! v0.8.1 closes the converse hole. Until then the file under verification and
17//! the caller's criterion were compiled as one crate, so the *subject could
18//! defeat its own gate* — shadowing a macro the criterion invoked, or deleting
19//! the criterion with a crate-level `#![cfg(any())]`. They are still one crate —
20//! that is what lets a criterion call a private `fn hello`, and making them two
21//! broke exactly that — but the criterion now sits in a module that re-imports
22//! the prelude macros explicitly, so a shadowing subject makes the name ambiguous
23//! instead of capturing it, and a probe compiled alongside the subject catches
24//! one that deleted its own contents. What a passing gate proves — and what it
25//! does not — is spelled out on [`Verification`].
26
27use std::path::{Path, PathBuf};
28use std::process::Stdio;
29
30use tokio::process::Command;
31
32use crate::error::{Error, Result};
33use crate::observe::{EventKind, RunEvent};
34use crate::policy::{Act, Effect, Policy};
35use crate::run::{refused, Watch};
36use crate::sandbox::{self, RunSpec, Sandbox, SandboxConfig};
37use crate::state::{PolicyEvent, SandboxEvent, Store};
38
39/// How the harness decides a task is done.
40///
41/// # What a passing gate proves
42///
43/// A pass means **the stated criterion was satisfied** under the harness's
44/// compile and run. It is not proof that the artifact is correct, and no
45/// execution gate can be. A criterion checks what it checks: a gate asserting
46/// `hello() == 42` is silent about everything else the file does.
47///
48/// The execution gates ([`Verification::CompilesRust`],
49/// [`Verification::RustTestPasses`], and the workspace variants) are stronger
50/// than the content gates ([`Verification::FileContains`],
51/// [`Verification::FileEquals`]) because a stub cannot compile and run — that is
52/// why 0.2.0 introduced them — but "stronger" is not "sufficient".
53///
54/// # What 0.8.1 changed
55///
56/// Until 0.8.1 the file under verification was compiled into the *same crate* as
57/// the caller's criterion, so it could change how that criterion resolved. A file
58/// defining `#[macro_export] macro_rules! assert` made
59/// `assert!(false, "this gate can never pass")` — which no correct
60/// implementation can satisfy — report a pass; a file opening with
61/// `#![cfg(any())]` deleted the criterion outright and the empty test binary
62/// exited 0. An agent found the first of these unprompted during io-cli 0.1.0's
63/// live runs (see `iterations/US-IO-HARNESS-0.8.0-I01`).
64///
65/// The subject and the criterion are still compiled as **one crate** — that is
66/// deliberate and is not the fix. Making the subject a separate crate was tried
67/// during 0.8.1 development and abandoned: privacy is a wall between crates, so
68/// an ordinary non-`pub` implementation began failing a gate it had always
69/// passed, and a passing implementation is allowed to be private. What changed
70/// is where the criterion sits inside that crate. It is appended in a child
71/// module that opens with `use super::*` — so `test_src` still calls the
72/// subject's items unqualified, private ones included — and that re-imports the
73/// prelude macros a criterion is likely to invoke *explicitly*. A subject
74/// defining `macro_rules! assert` now makes the name ambiguous (rustc E0659) and
75/// the gate fails to compile, rather than capturing it and passing an impossible
76/// criterion. A macro the subject exports under any other name still reaches the
77/// criterion through the glob.
78///
79/// The deletion attack is caught elsewhere, because one crate cannot catch it:
80/// the subject is separately compiled to an rlib with a probe item appended, and
81/// a second tiny crate is type-checked against that rlib. A subject that strips
82/// its own contents strips the probe too, and the reference fails to resolve.
83/// That separate subject compile is *not* what the criterion compiles against —
84/// its purposes are classifying an ordinary "this file does not compile" failure
85/// and hosting the probe.
86///
87/// This is a boundary against the file under verification, not against a hostile
88/// author with other tools. Verification runs the produced code, so it remains
89/// governed by the exec [`Policy`] and the 0.6.0 sandbox.
90///
91/// # Choosing one
92///
93/// A criterion is a field of the [`TaskContract`](crate::TaskContract), so the
94/// run has a definition of done before the model is asked anything:
95///
96/// ```
97/// use io_harness::{TaskContract, Verification};
98/// use std::time::Duration;
99///
100/// // Execution-based, and the pair a repository task normally wants: the listed
101/// // files are concatenated and compiled together, then `test_src` is compiled
102/// // beside them and run. "Together" is the point — each file compiling on its
103/// // own (`EachCompilesRust`) would not catch a caller updated out of step with
104/// // the function it calls.
105/// let contract = TaskContract::workspace(
106/// "make `parse` reject an empty input instead of panicking",
107/// "/path/to/repo",
108/// Verification::WorkspaceTestPasses {
109/// files: vec!["src/parse.rs".into(), "src/lib.rs".into()],
110/// test_src: r#"
111/// #[test]
112/// fn empty_input_is_an_error() {
113/// assert!(parse("").is_err());
114/// }
115/// "#
116/// .into(),
117/// },
118/// )
119/// .with_time_budget(Duration::from_secs(600));
120///
121/// // A pass proves this criterion was satisfied under the harness's compile and
122/// // run — nothing wider. `parse` is silent about everything else in the repo,
123/// // and a criterion is the only thing the gate can check.
124/// // https://github.com/initorigin/io-harness/blob/main/docs/guide/verification.md
125/// # let _ = contract;
126/// ```
127///
128/// The content variants are the weak tier and exist for outcomes that genuinely
129/// *are* about text. They cannot lie about what a file says and cannot confirm
130/// it works — a model satisfied `FileContains("fn hello")` in the 0.1.0 live run
131/// by writing that literal string, which does not compile:
132///
133/// ```
134/// use io_harness::Verification;
135///
136/// # async fn demo() -> io_harness::Result<()> {
137/// let cheap = Verification::FileContains("fn hello".into());
138/// // Both of these pass. Only one of them is a program.
139/// assert!(cheap.passes("src/hello.rs".as_ref(), "pub fn hello() -> u32 { 42 }").await?);
140/// assert!(cheap.passes("src/hello.rs".as_ref(), "fn hello").await?);
141///
142/// // `CompilesRust` fails the second: a substring stub does not type-check, and
143/// // since 0.8.1 a file that deletes its own items with `#![cfg(any())]` fails
144/// // too rather than compiling clean on nothing.
145/// # Ok(()) }
146/// ```
147#[derive(Debug, Clone)]
148pub enum Verification {
149 /// The file's contents must contain this text. Cheap, but gameable — a
150 /// model can satisfy it without producing working code.
151 FileContains(String),
152 /// The file's contents must equal this text exactly.
153 FileEquals(String),
154 /// Run a caller-supplied command in the workspace's execution sandbox and
155 /// require this exit status (0.17.0).
156 ///
157 /// One variant that covers every language the machine has a toolchain for,
158 /// and the reason the crate stopped being a Rust-shaped harness: `cargo
159 /// test`, `npm test`, `go test ./...`, `pytest`, `dotnet test`, `make check`
160 /// are all the same criterion with a different argv.
161 ///
162 /// `argv` is an array, program first, and there is no shell — `;`, `&&`,
163 /// `$( )` and a backtick are bytes inside one argument, not syntax. `argv[0]`
164 /// is what the [`Policy`] is asked about, exactly as `rustc` is on the
165 /// Rust-specific gates, and verification cannot prompt, so the spawn happens
166 /// only when a rule explicitly allows it.
167 ///
168 /// ```
169 /// use io_harness::{TaskContract, Verification};
170 ///
171 /// // A JavaScript project. Nothing on this path is Rust-aware.
172 /// let contract = TaskContract::workspace(
173 /// "make the failing test in test/parse.test.js pass",
174 /// "/path/to/js-repo",
175 /// Verification::Command {
176 /// argv: vec!["npm".into(), "test".into()],
177 /// expect_exit: 0,
178 /// },
179 /// );
180 /// # let _ = contract;
181 /// ```
182 ///
183 /// `expect_exit` is a status rather than a bool because "the linter found
184 /// nothing" and "the command ran" are different claims, and some tools say
185 /// so with a number. A command killed by a signal or by a sandbox cap never
186 /// satisfies the criterion, whatever `expect_exit` says: it did not exit.
187 ///
188 /// Workspace mode only — the command runs with the workspace root as its
189 /// working directory, and a single-file contract has no root to give it.
190 Command {
191 /// The command to run, program first. Passed to the OS as an array; no
192 /// shell parses it.
193 argv: Vec<String>,
194 /// The exit status that means the criterion is satisfied. Usually 0.
195 expect_exit: i32,
196 },
197 /// No gate at all (0.17.0). The run ends when the agent stops calling tools.
198 ///
199 /// This is what makes an open-ended task expressible. Until 0.17.0
200 /// [`TaskContract`](crate::TaskContract) took a `Verification` by value and
201 /// four of the five variants ran `rustc`, so "debug this production issue" or
202 /// "work out why the deploy fails" could not be *stated*, let alone run —
203 /// there was no criterion to name and no way to say there was none.
204 ///
205 /// An assistant turn carrying no tool call ends the run with
206 /// [`RunOutcome::Finished`](crate::RunOutcome::Finished), which is a distinct
207 /// outcome from a step cap, a stall and a budget stop — so an unattended run
208 /// that simply finished is never later mistaken for one that ran out. No
209 /// `done` tool is added: an unverified run gains no tool surface over a
210 /// verified one, and a model that has nothing left to do says so by saying
211 /// something.
212 ///
213 /// ```
214 /// use io_harness::{RunOutcome, TaskContract, Verification};
215 ///
216 /// let contract = TaskContract::workspace(
217 /// "work out why the nightly deploy has been failing and write up what you find",
218 /// "/path/to/repo",
219 /// Verification::None,
220 /// );
221 ///
222 /// // What "it worked" means for a run with no criterion: it finished on its
223 /// // own terms rather than hitting a ceiling. Nothing here claims the work is
224 /// // *correct* — with no gate, nothing could.
225 /// fn done(outcome: &RunOutcome) -> bool {
226 /// matches!(outcome, RunOutcome::Finished { .. })
227 /// }
228 /// # let _ = (contract, done);
229 /// ```
230 ///
231 /// What you give up is what a gate was ever worth: nothing checked the work.
232 /// Reach for [`Verification::Command`] whenever the task *has* a checkable
233 /// criterion — this variant is for the tasks that genuinely do not.
234 None,
235 /// The file must compile as a Rust library (`rustc --crate-type lib`), and
236 /// its items must survive to be type-checked. Execution-based: a
237 /// non-compiling stub fails.
238 ///
239 /// Since 0.8.1 the second half of that is enforced. A crate-level attribute
240 /// such as `#![cfg(any())]` strips every item *before* rustc examines it, so
241 /// a file whose body does not type-check compiled clean and passed. A subject
242 /// that deletes its own contents now fails. Legitimate crate-level attributes
243 /// — `#![allow(dead_code)]`, `#![no_std]` — are unaffected.
244 #[deprecated(
245 since = "0.17.0",
246 note = "use Verification::Command { argv: vec![\"cargo\".into(), \"build\".into()], \
247 expect_exit: 0 } — or any compiler invocation for the language in hand. \
248 Removed in 0.18.0."
249 )]
250 CompilesRust,
251 /// The file must compile, and `test_src` must compile against it and pass.
252 /// Execution-based.
253 ///
254 /// Since 0.8.1 the file under verification can no longer shadow the names
255 /// `test_src` uses, nor delete it. `test_src` is unchanged: it still refers to
256 /// the file's items unqualified, and still reaches *private* items — the two
257 /// remain one crate, deliberately, so an implementation does not have to be
258 /// `pub` to pass. See the
259 /// [type-level docs](Verification#what-a-passing-gate-proves) for what a
260 /// pass does and does not prove.
261 #[deprecated(
262 since = "0.17.0",
263 note = "use Verification::Command { argv: vec![\"cargo\".into(), \"test\".into()], \
264 expect_exit: 0 } and put the test in the project's own test suite, where the \
265 project's own tooling can run it. Removed in 0.18.0."
266 )]
267 RustTestPasses {
268 /// Rust source compiled against the file, e.g. a `#[test] fn`.
269 test_src: String,
270 },
271 /// (workspace/multi-file) A named file under the workspace root must contain
272 /// this text. Deterministic and language-agnostic — no compilation — so a
273 /// task whose success is "a file now holds X" can be verified directly. Like
274 /// [`Verification::FileContains`] it is gameable; use it when the outcome is
275 /// genuinely about content, or as a composed-tree checkpoint a parent reads.
276 WorkspaceFileContains {
277 /// File to read, relative to the workspace root.
278 file: PathBuf,
279 /// Text that must be present in it.
280 needle: String,
281 },
282 /// (workspace/multi-file, 0.14.0) A document under the workspace root must
283 /// contain this text **once its text has been extracted** — not in its raw
284 /// bytes.
285 ///
286 /// The distinction is the whole variant. A `.docx`, `.xlsx` and `.pptx` are
287 /// zips and a `.pdf` is a compressed object graph, so none of them is UTF-8.
288 /// [`Verification::WorkspaceFileContains`] reads with
289 /// `read_to_string(..).unwrap_or_default()`, which on a document yields the
290 /// empty string — so it reports "does not contain" for every document,
291 /// including one whose visible text plainly does contain the needle. It does
292 /// not fail loudly; it silently always fails. A criterion that can never pass
293 /// is worse than no criterion, because a run that ends `StepCapReached` looks
294 /// like an agent that could not do the work rather than a gate that was never
295 /// able to say yes.
296 ///
297 /// The reader is chosen by the file's extension: `.xlsx`, `.docx`, `.pptx`,
298 /// `.pdf`. Anything else is an error rather than a fallback to reading the
299 /// bytes as text — a criterion that silently degrades into a weaker check is
300 /// the failure mode this exists to remove.
301 ///
302 /// The variant exists in every build; only its implementation is behind the
303 /// document features. A build without them returns a typed
304 /// [`Error::Config`](crate::Error::Config) saying so, rather than the variant
305 /// vanishing and every match arm growing a `cfg`. Loud beats absent, which is
306 /// the same call single-file mode makes when handed a policy it cannot
307 /// enforce.
308 ///
309 /// Like the other content criteria this is gameable and does not prove the
310 /// document is *correct* — see the
311 /// [type-level docs](Verification#what-a-passing-gate-proves).
312 DocumentContains {
313 /// Document to read, relative to the workspace root.
314 file: PathBuf,
315 /// Text that must be present in its extracted text.
316 needle: String,
317 },
318 /// (workspace/multi-file) Every listed file — relative to the workspace root
319 /// — must compile on its own as a Rust library. The run only succeeds when
320 /// all of them do, so one wrong file fails the whole set.
321 ///
322 /// Hardened with [`Verification::CompilesRust`] in 0.8.1: each file goes
323 /// through the same check, so no listed file can pass by deleting itself.
324 EachCompilesRust(Vec<PathBuf>),
325 /// (workspace/multi-file) All listed files, concatenated in order, must
326 /// compile, and `test_src` must compile against them and pass. This proves
327 /// the edited files work *together*, not merely each in isolation.
328 ///
329 /// Hardened with [`Verification::RustTestPasses`] in 0.8.1: a shadowing
330 /// definition in any one of the files cannot defeat the gate, and a private
331 /// item in any of them still reaches `test_src`.
332 #[deprecated(
333 since = "0.17.0",
334 note = "use Verification::Command { argv: vec![\"cargo\".into(), \"test\".into()], \
335 expect_exit: 0 }, which runs the repository's own suite over the whole crate \
336 rather than a concatenation of the files you listed. Removed in 0.18.0."
337 )]
338 WorkspaceTestPasses {
339 /// Files (relative to the workspace root) concatenated into the subject.
340 files: Vec<PathBuf>,
341 /// Rust source compiled against the files, e.g. a `#[test] fn`.
342 test_src: String,
343 },
344}
345
346/// What the verification layer is allowed to spawn, and where to record it.
347///
348/// Verification cannot prompt — there is no approver on this path — so a
349/// command is spawned only when the policy explicitly *allows* it. Anything
350/// else, including [`Effect::Ask`], is refused.
351///
352/// A run builds one of these for you. Build it yourself to check a criterion
353/// outside a run — a CI step re-verifying what an agent produced, say — under
354/// the boundary the agent itself worked under:
355///
356/// ```
357/// use io_harness::{ExecGuard, Policy, Verification, TEST_BINARY};
358///
359/// # async fn demo() -> io_harness::Result<()> {
360/// // Compile-only: `rustc` may run, the produced test binary may not. The gate
361/// // type-checks the criterion against the code and never executes it, which is
362/// // what you want when the code came from a model and this host is not a sandbox
363/// // you are willing to lose.
364/// let policy = Policy::permissive()
365/// .layer("verify")
366/// .allow_exec("rustc")
367/// .deny_exec(TEST_BINARY);
368///
369/// let criterion = Verification::RustTestPasses {
370/// test_src: "#[test] fn it_answers() { assert_eq!(hello(), 42); }".into(),
371/// };
372/// // An allow the policy does not give is `Error::Refused`, not a silent skip —
373/// // a verification that was refused is not one that ran and failed.
374/// let outcome = criterion
375/// .passes_guarded(
376/// "src/hello.rs".as_ref(),
377/// "pub fn hello() -> u32 { 42 }",
378/// &ExecGuard::new(&policy),
379/// )
380/// .await;
381/// assert!(matches!(outcome, Err(io_harness::Error::Refused { .. })));
382/// # Ok(()) }
383/// ```
384///
385/// [`ExecGuard::tracing`] additionally writes every spawn's full argv against a
386/// run id, and [`ExecGuard::no_sandbox`] opts back to direct host execution —
387/// the sandbox is the default.
388pub struct ExecGuard<'a> {
389 policy: &'a Policy,
390 trace: Option<(&'a Store, i64, u32)>,
391 /// Where to announce what the trace records, and the depth to announce it
392 /// at. Separate from `trace` because a caller may attach a store without an
393 /// observer; every event below is written inside the `trace` block anyway,
394 /// so an event never reports something no row does.
395 watch: Option<(&'a Watch<'a>, u32)>,
396 /// How to sandbox the spawn. `Some` (the default) runs the compile inside an
397 /// ephemeral sandbox — the 0.6.0 default; `None` opts back to direct host
398 /// execution, the exact 0.5.0 behaviour.
399 sandbox: Option<SandboxConfig>,
400}
401
402impl<'a> ExecGuard<'a> {
403 /// Guard spawns with `policy`, recording nothing. Sandboxed by default.
404 pub fn new(policy: &'a Policy) -> Self {
405 Self {
406 policy,
407 trace: None,
408 watch: None,
409 sandbox: Some(SandboxConfig::default()),
410 }
411 }
412
413 /// Also record every spawn's full argv against `run_id` at `step`, so
414 /// argument-level enforcement can be added later against a real baseline.
415 pub fn tracing(mut self, store: &'a Store, run_id: i64, step: u32) -> Self {
416 self.trace = Some((store, run_id, step));
417 self
418 }
419
420 /// Also announce what it records to `watch`, at `depth` in the agent tree.
421 /// Crate-internal: an observer reaches the gate through the run, not through
422 /// a guard an embedder built by hand.
423 pub(crate) fn watching(mut self, watch: &'a Watch<'a>, depth: u32) -> Self {
424 self.watch = Some((watch, depth));
425 self
426 }
427
428 /// Run the compile inside `config`'s sandbox instead of the default one.
429 pub fn sandboxed(mut self, config: SandboxConfig) -> Self {
430 self.sandbox = Some(config);
431 self
432 }
433
434 /// Opt out of the sandbox: run the compile directly on the host, exactly as
435 /// 0.5.0 did. Additive and reversible — the sandbox is the default, not a
436 /// forced change.
437 pub fn no_sandbox(mut self) -> Self {
438 self.sandbox = None;
439 self
440 }
441
442 /// Allow nothing beyond what a permissive policy permits (the 0.3.0 path).
443 /// Sandboxed by default, like [`ExecGuard::new`].
444 fn permissive() -> ExecGuard<'static> {
445 static PERMISSIVE: std::sync::OnceLock<Policy> = std::sync::OnceLock::new();
446 ExecGuard {
447 policy: PERMISSIVE.get_or_init(Policy::permissive),
448 trace: None,
449 watch: None,
450 sandbox: Some(SandboxConfig::default()),
451 }
452 }
453
454 /// Check one spawn, recording its argv. Refuses unless explicitly allowed.
455 fn check(&self, program: &str, argv: &[String]) -> Result<()> {
456 let verdict = self.policy.check(Act::Exec, program);
457 let full = format!("{program} {}", argv.join(" "));
458 if let Some((store, run_id, step)) = self.trace {
459 let mut ev = if verdict.effect == Effect::Allow {
460 PolicyEvent::decision(step, "exec", &full, "allow", "policy")
461 } else {
462 PolicyEvent::refusal(step, "exec", &full)
463 };
464 ev.rule = verdict.rule.clone();
465 ev.layer = verdict.layer.clone();
466 let _ = store.record_event(run_id, &ev);
467 if verdict.effect != Effect::Allow {
468 if let Some((watch, depth)) = self.watch {
469 refused(watch, run_id, depth, &ev);
470 }
471 }
472 }
473 if verdict.effect == Effect::Allow {
474 Ok(())
475 } else {
476 Err(Error::Refused {
477 act: "exec".into(),
478 target: program.to_string(),
479 rule: verdict.rule,
480 layer: verdict.layer,
481 })
482 }
483 }
484
485 /// Record which phase of an execution gate failed, when a store is attached.
486 /// See [`crate::state::SandboxEvent::gate_phase_failed`] — this is what lets
487 /// an operator tell a criterion that could not compile against the subject
488 /// (the shape a pre-0.8.1 bypass takes) from a test that ran and failed.
489 fn record_gate_failure(&self, phase: &str) {
490 if let Some((store, run_id, step)) = self.trace {
491 self.sandboxed_event(store, &SandboxEvent::gate_phase_failed(run_id, step, phase));
492 }
493 }
494
495 /// Write one sandbox row and announce it, from the same value, so the event
496 /// cannot name a kind or a backend the `sandbox_events` row does not.
497 ///
498 /// The write stays `let _ =`: a trace failure must not fail the gate, and
499 /// telling an observer about a row that failed to land is better than a run
500 /// that dies because its audit trail did.
501 fn sandboxed_event(&self, store: &Store, e: &SandboxEvent) {
502 let _ = store.record_sandbox_event(e);
503 if let Some((watch, depth)) = self.watch {
504 watch.emit(RunEvent::at_depth(
505 e.run_id,
506 e.step,
507 depth,
508 EventKind::Sandbox {
509 kind: e.kind.clone(),
510 backend: e.backend.clone(),
511 },
512 ));
513 }
514 }
515
516 /// Record what a failing gate command printed, bounded.
517 ///
518 /// `Ok(false)` on its own says a criterion did not pass and nothing about
519 /// why, and the two causes need opposite responses: the agent's work being
520 /// wrong is a run to resume, and the test runner not being installed is a
521 /// machine to fix. Bounded because a build log is unbounded and this is a
522 /// trace row, and truncated from the tail, which is where a test runner puts
523 /// the failure.
524 fn record_gate_output(&self, output: &str) {
525 if output.trim().is_empty() {
526 return;
527 }
528 if let Some((store, run_id, step)) = self.trace {
529 let (bounded, _) =
530 crate::tools::exec::head_and_tail(output.trim(), GATE_OUTPUT_TRACE_CHARS);
531 self.sandboxed_event(store, &SandboxEvent::gate_output(run_id, step, &bounded));
532 }
533 }
534
535 /// Execute an already-policy-checked `argv` in `workdir`, returning whether
536 /// it succeeded. Routes through the sandbox when one is configured (the
537 /// 0.6.0 default) — so model-produced code never runs on the host directly —
538 /// and falls back to a direct spawn when the sandbox is opted out (0.5.0).
539 async fn exec(&self, argv: &[String], workdir: &Path) -> Result<bool> {
540 Ok(self.exec_output(argv, workdir).await?.exit == Some(0))
541 }
542
543 /// [`ExecGuard::exec`], keeping the exit status and the output rather than
544 /// reducing both to "did it work".
545 ///
546 /// The one execution path, which is why 0.17.0 put the detail here rather
547 /// than beside it: [`Verification::Command`] needs a *specific* exit status
548 /// and needs the command's own output for the trace, and the Rust-specific
549 /// gates need neither. A second spawn site for the second requirement would
550 /// be two places where the sandbox decision, the policy trace and the
551 /// lifecycle events could drift apart.
552 async fn exec_output(&self, argv: &[String], workdir: &Path) -> Result<GateRun> {
553 match &self.sandbox {
554 Some(cfg) => {
555 let sb = sandbox::select(cfg);
556 let backend = sb.backend();
557 // Record the sandbox lifecycle so an audit shows where code ran.
558 if let Some((store, run_id, step)) = self.trace {
559 self.sandboxed_event(
560 store,
561 &SandboxEvent::create(run_id, step, backend.as_str()),
562 );
563 self.sandboxed_event(
564 store,
565 &SandboxEvent::exec(run_id, step, backend.as_str(), &argv.join(" ")),
566 );
567 }
568 let outcome = sb
569 .run(RunSpec {
570 argv,
571 workdir,
572 limits: &cfg.limits,
573 allow_network: cfg.allow_network,
574 })
575 .await?;
576 if let Some((store, run_id, step)) = self.trace {
577 if let Some(cap) = outcome.cap_hit {
578 self.sandboxed_event(
579 store,
580 &SandboxEvent::cap_hit(run_id, step, cap.as_str()),
581 );
582 }
583 // The workdir is torn down when this call returns (tempdir
584 // drop in the caller); record the destroy now.
585 self.sandboxed_event(store, &SandboxEvent::destroy(run_id, step));
586 }
587 // A cap hit is a real failure of the gate, not a pass.
588 if !outcome.success() {
589 // Do not throw away what the command said about its own
590 // failure. `Ok(false)` on its own reads as "the model's code
591 // is wrong" whatever the real cause was; the compiler's own
592 // diagnostics are what tell the two apart, so keep them
593 // where the next diagnosis can read them.
594 tracing::debug!(
595 backend = backend.as_str(),
596 exit_code = ?outcome.exit_code,
597 cap_hit = ?outcome.cap_hit.map(|c| c.as_str()),
598 stderr = %outcome.stderr.trim(),
599 "sandboxed command failed"
600 );
601 }
602 Ok(GateRun {
603 // A cap hit is a real failure of the gate, not a pass, and
604 // the exit code a killed process reports is not one it chose
605 // — so it reports as no exit at all, which no `expect_exit`
606 // can match.
607 exit: if outcome.cap_hit.is_some() {
608 None
609 } else {
610 outcome.exit_code
611 },
612 output: joined_streams(&outcome.stdout, &outcome.stderr),
613 })
614 }
615 None => {
616 // Direct host execution — the exact 0.5.0 path.
617 let out = Command::new(&argv[0])
618 .args(&argv[1..])
619 .current_dir(workdir)
620 .stdin(Stdio::null())
621 .output()
622 .await?;
623 if !out.status.success() {
624 tracing::debug!(
625 exit_code = ?out.status.code(),
626 stderr = %String::from_utf8_lossy(&out.stderr).trim(),
627 "host command failed"
628 );
629 }
630 Ok(GateRun {
631 exit: out.status.code(),
632 output: joined_streams(
633 &String::from_utf8_lossy(&out.stdout),
634 &String::from_utf8_lossy(&out.stderr),
635 ),
636 })
637 }
638 }
639 }
640}
641
642/// One gate command's result: how it exited, and what it said.
643///
644/// `exit` is `None` when the command did not exit on its own terms — a signal,
645/// or a sandbox cap. That is deliberately not `Some(some_code)`: a killed
646/// process's status is the killer's, and matching it against a caller's
647/// `expect_exit` would let a command that was cut short pass a criterion.
648struct GateRun {
649 exit: Option<i32>,
650 output: String,
651}
652
653/// Both streams, in the order a reader wants them: what it printed, then what it
654/// complained about.
655///
656/// Shared with the `exec` tool's dispatch arm, so a command's output reads the
657/// same way whether it ran as a criterion or as a tool call.
658pub(crate) fn joined_streams(stdout: &str, stderr: &str) -> String {
659 match (stdout.trim(), stderr.trim()) {
660 ("", e) => e.to_string(),
661 (o, "") => o.to_string(),
662 (o, e) => format!("{o}\n{e}"),
663 }
664}
665
666/// How much of a failing gate command's output the trace keeps.
667///
668/// A test runner's useful output is a screenful; a build's is unbounded. This is
669/// a trace row rather than the model's context, so it is bounded on its own terms
670/// rather than by the run's per-observation cap.
671const GATE_OUTPUT_TRACE_CHARS: usize = 4_000;
672
673/// The logical name of the test binary verification builds and runs. Denying it
674/// while allowing `rustc` gives compile-only verification: the produced code is
675/// type-checked but never executed.
676///
677/// It is a placeholder, not a path — the real binary lives in a temp dir with a
678/// name the caller never sees, so there is nothing else to write a rule against.
679/// The one thing to do with it is decide whether model-produced code may run on
680/// this host at all:
681///
682/// ```
683/// use io_harness::{Act, Effect, Policy, TEST_BINARY};
684///
685/// // The tier `Policy::default()` ships: both spawns allowed by name, so the
686/// // execution gate works out of the box.
687/// assert_eq!(Policy::default().check(Act::Exec, TEST_BINARY).effect, Effect::Allow);
688///
689/// // Type-check but never execute. A deny beats an allow in any layer, so this
690/// // holds however permissive the layers beneath it are — which is what makes it
691/// // usable as an operator-level base under an app's own policy.
692/// let compile_only = Policy::default().layer("no-execution").deny_exec(TEST_BINARY);
693/// assert_eq!(compile_only.check(Act::Exec, "rustc").effect, Effect::Allow);
694/// assert_eq!(compile_only.check(Act::Exec, TEST_BINARY).effect, Effect::Deny);
695/// ```
696///
697/// The refusal is [`Error::Refused`], reported to the caller rather than folded
698/// into "the gate did not pass" — a criterion that was refused is not one that
699/// ran and failed.
700pub const TEST_BINARY: &str = "<test-binary>";
701
702impl Verification {
703 /// Check a single produced file against the criterion (0.1/0.2 single-file
704 /// mode). The multi-file variants belong to [`Verification::passes_in`] and
705 /// error here.
706 ///
707 /// `contents` is the current file text (already read by the caller).
708 pub async fn passes(&self, path: &Path, contents: &str) -> Result<bool> {
709 self.passes_guarded(path, contents, &ExecGuard::permissive())
710 .await
711 }
712
713 /// [`Verification::passes`], with every spawn checked against a policy.
714 #[allow(deprecated)] // the variants this release deprecates still work here
715 pub async fn passes_guarded(
716 &self,
717 _path: &Path,
718 contents: &str,
719 guard: &ExecGuard<'_>,
720 ) -> Result<bool> {
721 match self {
722 Verification::FileContains(needle) => Ok(contents.contains(needle)),
723 Verification::FileEquals(expected) => Ok(contents == expected),
724 Verification::CompilesRust => compile_source(contents, None, guard).await,
725 Verification::RustTestPasses { test_src } => {
726 compile_source(contents, Some(test_src), guard).await
727 }
728 // There is no gate, so there is nothing here that can pass. The run
729 // ends on an assistant turn that calls no tool — see
730 // [`RunOutcome::Finished`](crate::RunOutcome::Finished) — which is a
731 // decision the loop makes and not one this function can.
732 Verification::None => Ok(false),
733 Verification::Command { .. }
734 | Verification::EachCompilesRust(_)
735 | Verification::WorkspaceTestPasses { .. }
736 | Verification::DocumentContains { .. }
737 | Verification::WorkspaceFileContains { .. } => Err(Error::Config(
738 "multi-file verification requires a workspace root".into(),
739 )),
740 }
741 }
742
743 /// Check the criterion against a workspace `root` (0.3 multi-file mode). The
744 /// multi-file variants read their own files relative to `root`.
745 pub async fn passes_in(&self, root: &Path) -> Result<bool> {
746 self.passes_in_guarded(root, &ExecGuard::permissive()).await
747 }
748
749 /// [`Verification::passes_in`], with every spawn checked against a policy.
750 #[allow(deprecated)] // the variants this release deprecates still work here
751 pub async fn passes_in_guarded(&self, root: &Path, guard: &ExecGuard<'_>) -> Result<bool> {
752 match self {
753 // The one criterion that is not about Rust. Everything the gate
754 // needs is in the argv, so the same three lines check a Go test, a
755 // pytest run, an npm script or a Makefile target.
756 Verification::Command { argv, expect_exit } => {
757 let Some(program) = argv.first() else {
758 return Err(Error::Config(
759 "Verification::Command needs a non-empty argv".into(),
760 ));
761 };
762 guard.check(program, &argv[1..])?;
763 let run = guard.exec_output(argv, root).await?;
764 let passed = run.exit == Some(*expect_exit);
765 if !passed {
766 // What the command said about its own failure, where the next
767 // diagnosis can read it. Without this a failing gate is an
768 // outcome discriminant and nothing else, and "the agent's work
769 // is wrong" is indistinguishable from "the test runner is not
770 // installed".
771 guard.record_gate_failure(&format!(
772 "command exited {} (expected {expect_exit})",
773 run.exit
774 .map_or_else(|| "on a signal or a cap".to_string(), |c| c.to_string()),
775 ));
776 guard.record_gate_output(&run.output);
777 }
778 Ok(passed)
779 }
780 // No gate: see `passes_guarded`.
781 Verification::None => Ok(false),
782 Verification::WorkspaceFileContains { file, needle } => {
783 let src = tokio::fs::read_to_string(root.join(file))
784 .await
785 .unwrap_or_default();
786 Ok(src.contains(needle))
787 }
788 Verification::DocumentContains { file, needle } => {
789 Ok(extract_document_text(root, file)?.contains(needle))
790 }
791 Verification::EachCompilesRust(files) => {
792 for f in files {
793 let src = tokio::fs::read_to_string(root.join(f))
794 .await
795 .unwrap_or_default();
796 if !compile_source(&src, None, guard).await? {
797 return Ok(false);
798 }
799 }
800 Ok(true)
801 }
802 Verification::WorkspaceTestPasses { files, test_src } => {
803 let mut combined = String::new();
804 for f in files {
805 let src = tokio::fs::read_to_string(root.join(f))
806 .await
807 .unwrap_or_default();
808 combined.push_str(&src);
809 combined.push('\n');
810 }
811 compile_source(&combined, Some(test_src), guard).await
812 }
813 // Single-file variants against a workspace need a target file, which
814 // this method does not carry; use them in single-file mode.
815 _ => Err(Error::Config(
816 "single-file verification used in workspace mode".into(),
817 )),
818 }
819 }
820
821 /// Human-readable description fed to the model as the success criterion.
822 #[allow(deprecated)] // the variants this release deprecates still describe themselves
823 pub fn describe(&self) -> String {
824 match self {
825 Verification::Command { argv, expect_exit } => format!(
826 "running `{}` in the workspace root must exit {expect_exit}",
827 argv.join(" ")
828 ),
829 // Said plainly rather than left blank. A model told nothing about
830 // how it will be judged infers a criterion and works to that one;
831 // told there is none, it works to the goal, which is the whole point
832 // of the variant.
833 Verification::None => "there is no automated check. Do the work the goal describes, \
834 then reply without calling a tool to end the run"
835 .to_string(),
836 Verification::FileContains(needle) => {
837 format!("the file must contain exactly this text: {needle:?}")
838 }
839 Verification::FileEquals(expected) => {
840 format!("the file's entire contents must equal exactly: {expected:?}")
841 }
842 Verification::CompilesRust => {
843 "the file must compile as Rust (rustc --crate-type lib)".to_string()
844 }
845 Verification::RustTestPasses { test_src } => {
846 format!("the file must compile and pass this test:\n{test_src}")
847 }
848 Verification::WorkspaceFileContains { file, needle } => {
849 format!("the file {file:?} must contain exactly this text: {needle:?}")
850 }
851 Verification::DocumentContains { file, needle } => format!(
852 "the document {file:?} must contain this text once its text is \
853 extracted: {needle:?}"
854 ),
855 Verification::EachCompilesRust(files) => {
856 format!("each of these files must compile as Rust: {files:?}")
857 }
858 Verification::WorkspaceTestPasses { files, test_src } => format!(
859 "these files {files:?} must together compile and pass this test:\n{test_src}"
860 ),
861 }
862 }
863}
864
865/// The error for a document this build cannot read because its feature is off.
866/// Named rather than absent: a criterion that silently could not run is the
867/// failure mode this whole variant exists to remove.
868#[allow(dead_code)]
869fn missing_feature(ext: &str) -> Error {
870 Error::Config(format!(
871 "DocumentContains cannot read .{ext}: this build of io-harness does not \
872 have the \"{ext}\" feature enabled"
873 ))
874}
875
876/// A document's extracted text, chosen by extension.
877///
878/// Reads through a permissive [`Workspace`] rooted at `root`: verification is the
879/// *caller's* criterion, not the agent's action, so it is not subject to the
880/// policy the agent runs under — the same reason
881/// [`Verification::WorkspaceFileContains`] reads the file directly. The
882/// `Workspace` is here to reuse the readers, not to gate them.
883///
884/// An unknown extension is an error rather than a fallback to reading the bytes
885/// as text: a criterion that quietly becomes a weaker criterion is exactly what
886/// this variant exists to remove.
887fn extract_document_text(root: &Path, file: &Path) -> Result<String> {
888 #[allow(unused_variables)]
889 let rel = file.to_string_lossy().replace('\\', "/");
890 #[allow(unused_variables)]
891 let ws = crate::tools::Workspace::new(root);
892 let ext = file
893 .extension()
894 .map(|e| e.to_string_lossy().to_ascii_lowercase())
895 .unwrap_or_default();
896 match ext.as_str() {
897 #[cfg(feature = "xlsx")]
898 "xlsx" => crate::tools::documents::xlsx::read_sheet(&ws, &rel, None),
899 #[cfg(feature = "docx")]
900 "docx" => crate::tools::documents::docx::read_text(&ws, &rel),
901 #[cfg(feature = "pptx")]
902 "pptx" => crate::tools::documents::pptx::read_text(&ws, &rel),
903 #[cfg(feature = "pdf")]
904 "pdf" => crate::tools::documents::pdf::read_text(&ws, &rel),
905 // One arm per format, each present only when its feature is absent, so the
906 // "you did not build this in" answer is reachable in exactly the builds
907 // where it is true.
908 #[cfg(not(feature = "xlsx"))]
909 "xlsx" => Err(missing_feature("xlsx")),
910 #[cfg(not(feature = "docx"))]
911 "docx" => Err(missing_feature("docx")),
912 #[cfg(not(feature = "pptx"))]
913 "pptx" => Err(missing_feature("pptx")),
914 #[cfg(not(feature = "pdf"))]
915 "pdf" => Err(missing_feature("pdf")),
916 other => Err(Error::Config(format!(
917 "DocumentContains does not know how to read .{other}; it reads .xlsx, \
918 .docx, .pptx and .pdf, and deliberately does not fall back to \
919 matching raw bytes"
920 ))),
921 }
922}
923
924/// The argv that compiles the file under verification as its *own* crate, so
925/// nothing it declares can reach the crate the criterion lives in.
926fn subject_lib_args(subject: &Path, rlib: &Path) -> Vec<String> {
927 [
928 "--edition",
929 "2021",
930 "--crate-type",
931 "lib",
932 "--crate-name",
933 SUBJECT_CRATE,
934 ]
935 .iter()
936 .map(|s| s.to_string())
937 .chain([
938 subject.display().to_string(),
939 "-o".into(),
940 rlib.display().to_string(),
941 ])
942 .collect()
943}
944
945/// The argv verification builds to compile and link the test binary from the
946/// combined crate — the subject with the criterion module appended.
947fn test_build_args(combined: &Path, bin: &Path) -> Vec<String> {
948 ["--edition", "2021", "--test"]
949 .iter()
950 .map(|s| s.to_string())
951 .chain([
952 combined.display().to_string(),
953 "-o".into(),
954 bin.display().to_string(),
955 ])
956 .collect()
957}
958
959/// The argv that type-checks the probe crate against the compiled subject.
960///
961/// Every element is harness-constructed — no model or caller output reaches it —
962/// which is why the command policy gates the binary name and records argv rather
963/// than parsing it. See the 0.4.0 contract.
964fn probe_args(dir: &Path, probe: &Path, rlib: &Path) -> Vec<String> {
965 [
966 "--edition",
967 "2021",
968 "--crate-type",
969 "lib",
970 "--emit",
971 "metadata",
972 "--extern",
973 ]
974 .iter()
975 .map(|s| s.to_string())
976 .chain([
977 format!("{SUBJECT_CRATE}={}", rlib.display()),
978 "--out-dir".into(),
979 dir.display().to_string(),
980 probe.display().to_string(),
981 ])
982 .collect()
983}
984
985/// The crate name the file under verification is compiled under.
986const SUBJECT_CRATE: &str = "subject";
987
988/// Appended to the subject on the compile-only path, and referenced by
989/// [`PROBE_CRATE`]. A subject that deletes its own items — a crate-level
990/// `#![cfg(any())]` — deletes this too, and the reference then fails to resolve.
991/// The name is reserved: a subject defining it as well simply fails to compile.
992const PROBE_ITEM: &str = "pub fn __io_harness_probe() {}\n";
993
994/// The crate root that proves the subject's items actually exist.
995const PROBE_CRATE: &str = "extern crate subject;\n\
996 pub fn __io_harness_check() { subject::__io_harness_probe() }\n";
997
998/// Opens the module the harness wraps the caller's criterion in, appended to the
999/// subject so the two are one crate. Closed by [`CRITERION_CLOSE`].
1000///
1001/// This preamble is why an execution gate cannot be answered by the file it is
1002/// checking. Two properties, both load-bearing:
1003///
1004/// 1. The criterion is a *child module* of the subject's own crate, so
1005/// `use super::*` reaches the subject's items — including private ones.
1006/// `test_src` still calls `hello()` exactly as a 0.8.0 caller wrote it, and a
1007/// subject that writes an idiomatic non-`pub` `fn hello` still passes. The
1008/// 0.8.1 development build made the subject a separate crate instead, and that
1009/// is precisely what broke: privacy is a wall between crates, so an ordinary
1010/// private implementation failed a gate it had always passed. The live run for
1011/// F7 caught it — see `iterations/US-IO-HARNESS-0.8.1-I01`.
1012/// 2. The prelude macros the criterion is likely to invoke are re-imported
1013/// *explicitly*. A subject defining `macro_rules! assert` — exported or not —
1014/// then makes the name ambiguous (rustc E0659) rather than capturing it, so
1015/// the gate fails to compile instead of passing an impossible criterion. A
1016/// macro the subject exports under any *other* name still reaches the
1017/// criterion through the glob, which is what keeps this a fix rather than a
1018/// restriction.
1019///
1020/// The deletion attack — a crate-level `#![cfg(any())]` that strips the criterion
1021/// along with everything else, leaving a test binary that runs zero tests and
1022/// exits 0 — is not this preamble's job. Being one crate again, it cannot be. It
1023/// is caught before this point by the same probe the compile-only path uses.
1024const CRITERION_OPEN: &str = "\n#[cfg(test)]
1025mod __io_harness_criterion {
1026#[allow(unused_imports)]
1027use ::core::{assert, assert_eq, assert_ne, debug_assert, debug_assert_eq, debug_assert_ne,
1028 matches, panic, todo, unimplemented, unreachable, write, writeln, format_args};
1029#[allow(unused_imports)]
1030use ::std::{dbg, eprint, eprintln, format, print, println, vec};
1031#[allow(unused_imports)] use super::*;
1032";
1033
1034/// Closes [`CRITERION_OPEN`].
1035const CRITERION_CLOSE: &str = "\n}\n";
1036
1037/// Compile `source` with `rustc` in a throwaway temp dir. With `test_src`,
1038/// append it and run the resulting test binary. Returns whether the gate
1039/// passed. `rustc` touches no network and the temp dir is removed on drop.
1040async fn compile_source(
1041 source: &str,
1042 test_src: Option<&str>,
1043 guard: &ExecGuard<'_>,
1044) -> Result<bool> {
1045 let dir = tempfile::tempdir()?; // removed on drop — nothing left behind
1046
1047 match test_src {
1048 None => {
1049 // Compile the subject as its own crate, with a probe item appended,
1050 // then type-check a second crate that *references* the probe.
1051 //
1052 // The second compile is what makes the gate honest. Before 0.8.1 the
1053 // subject was compiled alone, and "it compiled" was taken to mean its
1054 // contents were type-checked. It does not: a crate-level
1055 // `#![cfg(any())]` strips every item before rustc examines it, so a
1056 // body as ill-typed as `pub fn hello() -> u32 { "not a u32" }`
1057 // compiled clean and passed. A subject that deleted itself now fails,
1058 // because the probe went with it and the probe crate cannot find it.
1059 //
1060 // The probe rather than the more obvious `include!` of the subject
1061 // from a harness-authored root: that would reject crate-level inner
1062 // attributes outright, which also fails an honest file opening with
1063 // `#![allow(dead_code)]` or `#![no_std]`. Legitimate attributes keep
1064 // working here — only *deleting the crate's contents* is caught.
1065 let subject = dir.path().join("subject.rs");
1066 tokio::fs::write(&subject, format!("{source}\n{PROBE_ITEM}")).await?;
1067 let rlib = dir.path().join("libsubject.rlib");
1068 let args = subject_lib_args(&subject, &rlib);
1069 guard.check("rustc", &args)?;
1070 let argv = std::iter::once("rustc".to_string())
1071 .chain(args.iter().cloned())
1072 .collect::<Vec<_>>();
1073 if !guard.exec(&argv, dir.path()).await? {
1074 guard.record_gate_failure("subject-compile");
1075 return Ok(false);
1076 }
1077
1078 let probe = dir.path().join("probe.rs");
1079 tokio::fs::write(&probe, PROBE_CRATE).await?;
1080 let args = probe_args(dir.path(), &probe, &rlib);
1081 guard.check("rustc", &args)?;
1082 let argv = std::iter::once("rustc".to_string())
1083 .chain(args.iter().cloned())
1084 .collect::<Vec<_>>();
1085 let passed = guard.exec(&argv, dir.path()).await?;
1086 if !passed {
1087 // The subject compiled but its items are gone.
1088 guard.record_gate_failure("subject-emptied");
1089 }
1090 Ok(passed)
1091 }
1092 Some(test) => {
1093 // Three defences, because the two attacks this release closes are
1094 // different and no single structure stops both without cost.
1095 //
1096 // The subject is compiled alone first, with the probe appended. That
1097 // classifies an ordinary "the file does not compile" failure, and the
1098 // probe reference then catches a subject that deleted its own items —
1099 // a crate-level `#![cfg(any())]`, which would otherwise strip the
1100 // criterion too and leave a test binary that runs zero tests and
1101 // exits 0. Same mechanism as the compile-only path above.
1102 //
1103 // Only then is the criterion appended, as a module of the subject's
1104 // own crate. It is deliberately NOT a separate crate: that was tried
1105 // during 0.8.1 and it broke an ordinary private implementation, since
1106 // privacy is a wall between crates. Shadowing is stopped inside the
1107 // module by CRITERION_OPEN instead.
1108 let subject = dir.path().join("subject.rs");
1109 tokio::fs::write(&subject, format!("{source}\n{PROBE_ITEM}")).await?;
1110 let rlib = dir.path().join("libsubject.rlib");
1111
1112 let args = subject_lib_args(&subject, &rlib);
1113 guard.check("rustc", &args)?;
1114 let subject_argv = std::iter::once("rustc".to_string())
1115 .chain(args.iter().cloned())
1116 .collect::<Vec<_>>();
1117 if !guard.exec(&subject_argv, dir.path()).await? {
1118 // The subject does not compile: the gate fails exactly as it did
1119 // in 0.8.0.
1120 guard.record_gate_failure("subject-compile");
1121 return Ok(false);
1122 }
1123
1124 let probe = dir.path().join("probe.rs");
1125 tokio::fs::write(&probe, PROBE_CRATE).await?;
1126 let args = probe_args(dir.path(), &probe, &rlib);
1127 guard.check("rustc", &args)?;
1128 let probe_argv = std::iter::once("rustc".to_string())
1129 .chain(args.iter().cloned())
1130 .collect::<Vec<_>>();
1131 if !guard.exec(&probe_argv, dir.path()).await? {
1132 // The subject compiled but its items are gone — so the criterion
1133 // would be gone too, and the test binary would pass on nothing.
1134 guard.record_gate_failure("subject-emptied");
1135 return Ok(false);
1136 }
1137
1138 let combined = dir.path().join("combined.rs");
1139 tokio::fs::write(
1140 &combined,
1141 format!("{source}\n{CRITERION_OPEN}{test}{CRITERION_CLOSE}"),
1142 )
1143 .await?;
1144 let bin = dir.path().join("t");
1145
1146 let args = test_build_args(&combined, &bin);
1147 guard.check("rustc", &args)?;
1148 let build_argv = std::iter::once("rustc".to_string())
1149 .chain(args.iter().cloned())
1150 .collect::<Vec<_>>();
1151 if !guard.exec(&build_argv, dir.path()).await? {
1152 // The interesting failure: the subject compiles on its own, but
1153 // the criterion will not compile beside it. A subject shadowing
1154 // `assert!` lands here, as an E0659 ambiguity.
1155 guard.record_gate_failure("criterion-compile");
1156 return Ok(false);
1157 }
1158
1159 // The produced binary is its own spawn: denying TEST_BINARY while
1160 // allowing rustc type-checks the code without ever running it.
1161 guard.check(TEST_BINARY, &[bin.display().to_string()])?;
1162 let passed = guard.exec(&[bin.display().to_string()], dir.path()).await?;
1163 if !passed {
1164 guard.record_gate_failure("test-run");
1165 }
1166 Ok(passed)
1167 }
1168 }
1169}
1170
1171// The Rust-specific variants are deprecated in 0.17.0 and removed in 0.18.0.
1172// Their tests stay, unchanged, until the variants go: they are the specification
1173// of what each one proves, and F10's claim that a 0.16.2-era contract still works
1174// is only worth anything if the assertions behind it are still running.
1175#[allow(deprecated)]
1176#[cfg(test)]
1177mod tests {
1178 use super::*;
1179 use std::path::PathBuf;
1180
1181 async fn passes(v: &Verification, contents: &str) -> bool {
1182 // Content variants ignore the path; use a dummy.
1183 v.passes(&PathBuf::from("unused"), contents).await.unwrap()
1184 }
1185
1186 #[tokio::test]
1187 async fn contains_passes_and_fails() {
1188 let v = Verification::FileContains("fn hello".into());
1189 assert!(passes(&v, "pub fn hello() {}").await);
1190 assert!(!passes(&v, "pub fn world() {}").await);
1191 }
1192
1193 #[tokio::test]
1194 async fn equals_is_exact() {
1195 let v = Verification::FileEquals("a".into());
1196 assert!(passes(&v, "a").await);
1197 assert!(!passes(&v, "a ").await);
1198 }
1199
1200 #[tokio::test]
1201 async fn compiles_rust_rejects_stub_accepts_real() {
1202 let dir = tempfile::tempdir().unwrap();
1203 let file = dir.path().join("hello.rs");
1204
1205 // The I01 case: the literal substring, which is not valid Rust.
1206 tokio::fs::write(&file, "fn hello").await.unwrap();
1207 assert!(!Verification::CompilesRust
1208 .passes(&file, "fn hello")
1209 .await
1210 .unwrap());
1211
1212 let good = "pub fn hello() -> u32 { 42 }\n";
1213 tokio::fs::write(&file, good).await.unwrap();
1214 assert!(Verification::CompilesRust
1215 .passes(&file, good)
1216 .await
1217 .unwrap());
1218 }
1219
1220 #[tokio::test]
1221 async fn rust_test_passes_only_when_test_passes() {
1222 let dir = tempfile::tempdir().unwrap();
1223 let file = dir.path().join("hello.rs");
1224 let good = "pub fn hello() -> u32 { 42 }\n";
1225 tokio::fs::write(&file, good).await.unwrap();
1226
1227 let ok = Verification::RustTestPasses {
1228 test_src: "#[test] fn t() { assert_eq!(hello(), 42); }".into(),
1229 };
1230 assert!(ok.passes(&file, good).await.unwrap());
1231
1232 let bad = Verification::RustTestPasses {
1233 test_src: "#[test] fn t() { assert_eq!(hello(), 41); }".into(),
1234 };
1235 assert!(!bad.passes(&file, good).await.unwrap());
1236 }
1237
1238 #[tokio::test]
1239 async fn a_command_absent_from_the_allow_list_is_refused_not_failed() {
1240 // Denying rustc must refuse, and the refusal must be distinguishable
1241 // from a verification that ran and returned false.
1242 let policy = Policy::default().layer("locked").deny_exec("rustc");
1243 let guard = ExecGuard::new(&policy);
1244 let good = "pub fn hello() -> u32 { 42 }\n";
1245
1246 let refused = Verification::CompilesRust
1247 .passes_guarded(Path::new("x.rs"), good, &guard)
1248 .await;
1249 assert!(
1250 matches!(refused, Err(Error::Refused { ref target, .. }) if target == "rustc"),
1251 "expected a typed refusal, got {refused:?}"
1252 );
1253
1254 // The same code under the default policy runs and passes — so the
1255 // refusal above is the policy talking, not a broken compile.
1256 let allowed = Policy::default();
1257 assert!(Verification::CompilesRust
1258 .passes_guarded(Path::new("x.rs"), good, &ExecGuard::new(&allowed))
1259 .await
1260 .unwrap());
1261 }
1262
1263 #[tokio::test]
1264 async fn denying_the_test_binary_type_checks_without_running_it() {
1265 // rustc allowed, the produced binary denied: the code compiles but is
1266 // never executed, so the gate refuses rather than reporting a result.
1267 let policy = Policy::default().layer("no-exec").deny_exec(TEST_BINARY);
1268 let v = Verification::RustTestPasses {
1269 test_src: "#[test] fn t() { assert_eq!(hello(), 42); }".into(),
1270 };
1271 let out = v
1272 .passes_guarded(
1273 Path::new("x.rs"),
1274 "pub fn hello() -> u32 { 42 }\n",
1275 &ExecGuard::new(&policy),
1276 )
1277 .await;
1278 assert!(
1279 matches!(out, Err(Error::Refused { ref target, .. }) if target == TEST_BINARY),
1280 "expected the run of the test binary to be refused, got {out:?}"
1281 );
1282 }
1283
1284 #[tokio::test]
1285 async fn a_verification_with_no_policy_still_spawns_as_0_3_0_did() {
1286 assert!(Verification::CompilesRust
1287 .passes(Path::new("x.rs"), "pub fn hello() -> u32 { 42 }\n")
1288 .await
1289 .unwrap());
1290 }
1291
1292 #[tokio::test]
1293 async fn each_compiles_rust_fails_if_any_file_fails() {
1294 let dir = tempfile::tempdir().unwrap();
1295 let root = dir.path();
1296 std::fs::write(root.join("a.rs"), "pub fn a() -> u32 { 1 }\n").unwrap();
1297 std::fs::write(root.join("b.rs"), "pub fn b() -> u32 { 2 }\n").unwrap();
1298
1299 let v = Verification::EachCompilesRust(vec!["a.rs".into(), "b.rs".into()]);
1300 assert!(v.passes_in(root).await.unwrap());
1301
1302 // Break one file: the whole set must now fail.
1303 std::fs::write(root.join("b.rs"), "pub fn b").unwrap();
1304 assert!(!v.passes_in(root).await.unwrap());
1305 }
1306
1307 #[tokio::test]
1308 async fn workspace_test_passes_only_when_files_work_together() {
1309 let dir = tempfile::tempdir().unwrap();
1310 let root = dir.path();
1311 std::fs::write(root.join("a.rs"), "pub fn a() -> u32 { 40 }\n").unwrap();
1312 std::fs::write(root.join("b.rs"), "pub fn b() -> u32 { 2 }\n").unwrap();
1313
1314 let v = Verification::WorkspaceTestPasses {
1315 files: vec!["a.rs".into(), "b.rs".into()],
1316 test_src: "#[test] fn t() { assert_eq!(a() + b(), 42); }".into(),
1317 };
1318 assert!(v.passes_in(root).await.unwrap());
1319
1320 // One file wrong → the cross-file test fails.
1321 std::fs::write(root.join("b.rs"), "pub fn b() -> u32 { 99 }\n").unwrap();
1322 assert!(!v.passes_in(root).await.unwrap());
1323 }
1324
1325 #[tokio::test]
1326 async fn multi_file_variant_errors_in_single_file_mode() {
1327 let v = Verification::EachCompilesRust(vec!["a.rs".into()]);
1328 assert!(v.passes(&PathBuf::from("unused"), "").await.is_err());
1329 }
1330
1331 // 0.14.0 — the document criterion. The decisive test is the third one: a
1332 // criterion that cannot tell a document's text from its container bytes is
1333 // `WorkspaceFileContains` with extra steps and a longer name.
1334
1335 #[cfg(feature = "docx")]
1336 #[tokio::test]
1337 async fn a_document_criterion_passes_on_text_the_document_actually_shows() {
1338 let dir = tempfile::tempdir().unwrap();
1339 let ws = crate::tools::Workspace::new(dir.path());
1340 crate::tools::documents::docx::write_new(
1341 &ws,
1342 "report.docx",
1343 &["Quarterly revenue rose".to_string()],
1344 )
1345 .unwrap();
1346
1347 let v = Verification::DocumentContains {
1348 file: "report.docx".into(),
1349 needle: "revenue rose".into(),
1350 };
1351 assert!(v.passes_in(dir.path()).await.unwrap());
1352 }
1353
1354 #[cfg(feature = "docx")]
1355 #[tokio::test]
1356 async fn a_document_criterion_fails_on_text_the_document_does_not_show() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let ws = crate::tools::Workspace::new(dir.path());
1359 crate::tools::documents::docx::write_new(&ws, "report.docx", &["Nothing here".to_string()])
1360 .unwrap();
1361
1362 let v = Verification::DocumentContains {
1363 file: "report.docx".into(),
1364 needle: "revenue rose".into(),
1365 };
1366 assert!(!v.passes_in(dir.path()).await.unwrap());
1367 }
1368
1369 /// THE test for this variant, and it found something sharper than expected.
1370 ///
1371 /// The first draft assumed `WorkspaceFileContains` would match a needle that
1372 /// appears in a `.docx`'s container bytes and wrongly pass. It does not — it
1373 /// reads with `read_to_string(..).unwrap_or_default()`, a document is not
1374 /// UTF-8, so it reads the empty string and reports "does not contain" for
1375 /// EVERY document. The wrong answer it gives is not a false pass, it is a
1376 /// permanent false fail, and silently.
1377 ///
1378 /// So both halves are asserted on a document whose text genuinely contains
1379 /// the needle: the byte criterion says no, the document criterion says yes.
1380 /// Plus the container-bytes case, so the reader is pinned as reading text
1381 /// rather than bytes in either direction.
1382 #[cfg(feature = "docx")]
1383 #[tokio::test]
1384 async fn the_byte_criterion_cannot_read_a_document_and_this_one_can() {
1385 let dir = tempfile::tempdir().unwrap();
1386 let ws = crate::tools::Workspace::new(dir.path());
1387 crate::tools::documents::docx::write_new(
1388 &ws,
1389 "report.docx",
1390 &["Quarterly revenue rose".to_string()],
1391 )
1392 .unwrap();
1393
1394 let needle = "revenue rose";
1395 let byte_match = Verification::WorkspaceFileContains {
1396 file: "report.docx".into(),
1397 needle: needle.into(),
1398 };
1399 assert!(
1400 !byte_match.passes_in(dir.path()).await.unwrap(),
1401 "the byte criterion reads a document as empty and can never pass — \
1402 this is the wrong answer the variant exists to fix"
1403 );
1404
1405 let text_match = Verification::DocumentContains {
1406 file: "report.docx".into(),
1407 needle: needle.into(),
1408 };
1409 assert!(
1410 text_match.passes_in(dir.path()).await.unwrap(),
1411 "the document criterion reads what the document shows"
1412 );
1413
1414 // And the other direction: an entry name lives in the container's bytes
1415 // and never in the text, so it must not match either.
1416 let container = "word/document.xml";
1417 let raw = std::fs::read(dir.path().join("report.docx")).unwrap();
1418 assert!(
1419 String::from_utf8_lossy(&raw).contains(container),
1420 "the fixture must carry the entry name in its bytes, or the next \
1421 assertion proves nothing"
1422 );
1423 let container_match = Verification::DocumentContains {
1424 file: "report.docx".into(),
1425 needle: container.into(),
1426 };
1427 assert!(
1428 !container_match.passes_in(dir.path()).await.unwrap(),
1429 "a needle only in the container must not match the text"
1430 );
1431 }
1432
1433 #[tokio::test]
1434 async fn a_document_criterion_refuses_a_format_it_cannot_read() {
1435 let dir = tempfile::tempdir().unwrap();
1436 std::fs::write(dir.path().join("notes.txt"), "revenue rose").unwrap();
1437
1438 let v = Verification::DocumentContains {
1439 file: "notes.txt".into(),
1440 needle: "revenue rose".into(),
1441 };
1442 let err = v.passes_in(dir.path()).await.unwrap_err();
1443 assert!(
1444 err.to_string().contains("txt"),
1445 "the error names the extension it will not guess at, got {err}"
1446 );
1447 }
1448
1449 #[tokio::test]
1450 async fn a_document_criterion_needs_a_workspace_root() {
1451 let v = Verification::DocumentContains {
1452 file: "report.docx".into(),
1453 needle: "x".into(),
1454 };
1455 let err = v
1456 .passes(std::path::Path::new("f.rs"), "irrelevant")
1457 .await
1458 .unwrap_err();
1459 assert!(err.to_string().contains("workspace root"), "got {err}");
1460 }
1461}