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