Skip to main content

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}