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