1pub mod annotations;
40pub mod fidelity;
41pub mod overlay_fs;
42pub mod process_tape;
43pub mod tape;
44#[cfg(feature = "testbench-wasi")]
45pub mod wasi_process;
46
47use std::path::PathBuf;
48use std::sync::Arc;
49
50use crate::clock_mock::leak_audit::{self, ClockLeak};
51use crate::clock_mock::{install_override, ClockOverrideGuard, MockClock};
52use crate::egress::reset_egress_policy_for_host;
53
54use overlay_fs::{install_overlay, OverlayFs, OverlayFsGuard};
55use process_tape::{install_process_tape, ProcessTape, ProcessTapeGuard, ProcessTapeMode};
56use tape::{install_recorder, TapeHeader, TapeRecorder, TapeRecorderGuard};
57
58#[derive(Debug, Default, Clone)]
61pub struct Testbench {
62 pub clock: ClockConfig,
63 pub llm: LlmConfig,
64 pub filesystem: FilesystemConfig,
65 pub subprocess: SubprocessConfig,
66 pub network: NetworkConfig,
67 pub tape: TapeConfig,
68}
69
70#[derive(Debug, Default, Clone)]
73pub enum ClockConfig {
74 #[default]
76 Real,
77 Paused { starting_at_ms: i64 },
80}
81
82#[derive(Debug, Default, Clone)]
88pub enum LlmConfig {
89 #[default]
91 Real,
92 Replay { fixture: PathBuf },
94 Record { fixture: PathBuf },
96}
97
98#[derive(Debug, Default, Clone)]
100pub enum FilesystemConfig {
101 #[default]
103 Real,
104 Overlay { worktree: PathBuf },
108}
109
110#[derive(Debug, Default, Clone)]
112pub enum SubprocessConfig {
113 #[default]
115 Real,
116 Record { tape: PathBuf },
119 Replay { tape: PathBuf },
122 WasiToolchain { dir: PathBuf },
130}
131
132#[derive(Debug, Default, Clone)]
135pub enum NetworkConfig {
136 #[default]
138 Real,
139 DenyByDefault {
143 allow: Vec<String>,
146 },
147}
148
149#[derive(Debug, Default, Clone)]
155pub enum TapeConfig {
156 #[default]
157 Off,
158 Emit {
159 path: PathBuf,
160 argv: Vec<String>,
164 script_path: Option<String>,
167 },
168}
169
170impl Testbench {
171 pub fn builder() -> TestbenchBuilder {
173 TestbenchBuilder::default()
174 }
175
176 pub fn activate(self) -> Result<TestbenchSession, TestbenchError> {
179 TestbenchSession::install(self)
180 }
181}
182
183#[derive(Debug, Default, Clone)]
185pub struct TestbenchBuilder {
186 bench: Testbench,
187}
188
189impl TestbenchBuilder {
190 pub fn paused_clock_at_ms(mut self, starting_at_ms: i64) -> Self {
191 self.bench.clock = ClockConfig::Paused { starting_at_ms };
192 self
193 }
194
195 pub fn replay_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
196 self.bench.llm = LlmConfig::Replay {
197 fixture: fixture.into(),
198 };
199 self
200 }
201
202 pub fn record_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
203 self.bench.llm = LlmConfig::Record {
204 fixture: fixture.into(),
205 };
206 self
207 }
208
209 pub fn fs_overlay(mut self, worktree: impl Into<PathBuf>) -> Self {
210 self.bench.filesystem = FilesystemConfig::Overlay {
211 worktree: worktree.into(),
212 };
213 self
214 }
215
216 pub fn record_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
217 self.bench.subprocess = SubprocessConfig::Record { tape: tape.into() };
218 self
219 }
220
221 pub fn replay_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
222 self.bench.subprocess = SubprocessConfig::Replay { tape: tape.into() };
223 self
224 }
225
226 pub fn wasi_toolchain(mut self, dir: impl Into<PathBuf>) -> Self {
229 self.bench.subprocess = SubprocessConfig::WasiToolchain { dir: dir.into() };
230 self
231 }
232
233 pub fn deny_network(mut self) -> Self {
234 self.bench.network = NetworkConfig::DenyByDefault { allow: Vec::new() };
235 self
236 }
237
238 pub fn allow_network(mut self, allow: impl IntoIterator<Item = String>) -> Self {
239 self.bench.network = NetworkConfig::DenyByDefault {
240 allow: allow.into_iter().collect(),
241 };
242 self
243 }
244
245 pub fn emit_tape(mut self, path: impl Into<PathBuf>) -> Self {
246 self.bench.tape = TapeConfig::Emit {
247 path: path.into(),
248 argv: Vec::new(),
249 script_path: None,
250 };
251 self
252 }
253
254 pub fn emit_tape_for(
255 mut self,
256 path: impl Into<PathBuf>,
257 script_path: Option<String>,
258 argv: Vec<String>,
259 ) -> Self {
260 self.bench.tape = TapeConfig::Emit {
261 path: path.into(),
262 argv,
263 script_path,
264 };
265 self
266 }
267
268 pub fn build(self) -> Testbench {
269 self.bench
270 }
271}
272
273#[must_use = "the testbench tears down on drop; bind the handle to a `_session` local"]
276pub struct TestbenchSession {
277 _clock: Option<ClockOverrideGuard>,
278 _process: Option<ProcessTapeGuard>,
279 _overlay: Option<OverlayFsGuard>,
280 _recorder: Option<TapeRecorderGuard>,
281 process_tape: Option<Arc<ProcessTape>>,
282 overlay: Option<Arc<OverlayFs>>,
283 recorder: Option<Arc<TapeRecorder>>,
284 tape_path: Option<PathBuf>,
285 tape_started_at_unix_ms: Option<i64>,
286 tape_script_path: Option<String>,
287 tape_argv: Vec<String>,
288 subprocess_mode: ProcessTapeMode,
289 subprocess_tape_path: Option<PathBuf>,
290 #[cfg(feature = "testbench-wasi")]
291 _wasi_toolchain: Option<wasi_process::WasiToolchainGuard>,
292 saved_egress_env: Option<SavedEgressEnv>,
296}
297
298#[derive(Debug, Clone)]
299struct SavedEgressEnv {
300 default: Option<String>,
301 allow: Option<String>,
302 deny: Option<String>,
303}
304
305impl TestbenchSession {
306 fn install(bench: Testbench) -> Result<Self, TestbenchError> {
307 leak_audit::reset();
311
312 let (clock_guard, started_at_unix_ms) = match bench.clock {
313 ClockConfig::Real => (None, None),
314 ClockConfig::Paused { starting_at_ms } => (
315 Some(install_override(MockClock::at_wall_ms(starting_at_ms))),
316 Some(starting_at_ms),
317 ),
318 };
319
320 let _llm_config = bench.llm;
324
325 #[cfg(feature = "testbench-wasi")]
326 let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
327
328 let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
329 match bench.subprocess {
330 SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
331 SubprocessConfig::Record { tape } => {
332 let active = Arc::new(ProcessTape::recording());
333 let guard = install_process_tape(Arc::clone(&active));
334 (
335 Some(Arc::clone(&active)),
336 Some(guard),
337 ProcessTapeMode::Record,
338 Some(tape),
339 )
340 }
341 SubprocessConfig::Replay { tape } => {
342 let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
343 let active = Arc::new(loaded);
344 let guard = install_process_tape(Arc::clone(&active));
345 (
346 Some(Arc::clone(&active)),
347 Some(guard),
348 ProcessTapeMode::Replay,
349 Some(tape),
350 )
351 }
352 #[cfg(feature = "testbench-wasi")]
353 SubprocessConfig::WasiToolchain { dir } => {
354 if !dir.exists() {
355 return Err(TestbenchError::Subprocess(format!(
356 "wasi toolchain directory does not exist: {}",
357 dir.display()
358 )));
359 }
360 wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
361 (None, None, ProcessTapeMode::Replay, None)
362 }
363 #[cfg(not(feature = "testbench-wasi"))]
364 SubprocessConfig::WasiToolchain { .. } => {
365 return Err(TestbenchError::Subprocess(
366 "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
367 ));
368 }
369 };
370
371 let (overlay, overlay_guard) = match bench.filesystem {
372 FilesystemConfig::Real => (None, None),
373 FilesystemConfig::Overlay { worktree } => {
374 let overlay = Arc::new(OverlayFs::rooted_at(worktree));
375 let guard = install_overlay(Arc::clone(&overlay));
376 (Some(overlay), Some(guard))
377 }
378 };
379
380 let saved_egress_env = match bench.network {
381 NetworkConfig::Real => None,
382 NetworkConfig::DenyByDefault { allow } => {
383 let saved = SavedEgressEnv {
384 default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
385 allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
386 deny: std::env::var("HARN_EGRESS_DENY").ok(),
387 };
388 reset_egress_policy_for_host();
392 std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
393 if allow.is_empty() {
394 std::env::remove_var("HARN_EGRESS_ALLOW");
395 } else {
396 std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
397 }
398 std::env::remove_var("HARN_EGRESS_DENY");
399 Some(saved)
400 }
401 };
402
403 let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
404 TapeConfig::Off => (None, None, None, Vec::new(), None),
405 TapeConfig::Emit {
406 path,
407 argv,
408 script_path,
409 } => {
410 let recorder = Arc::new(TapeRecorder::new());
411 let guard = install_recorder(Arc::clone(&recorder));
412 (
413 Some(Arc::clone(&recorder)),
414 Some(guard),
415 Some(path),
416 argv,
417 script_path,
418 )
419 }
420 };
421
422 Ok(Self {
423 _clock: clock_guard,
424 _process: process_guard,
425 _overlay: overlay_guard,
426 _recorder: recorder_guard,
427 process_tape,
428 overlay,
429 recorder,
430 tape_path,
431 tape_started_at_unix_ms: started_at_unix_ms,
432 tape_script_path,
433 tape_argv,
434 subprocess_mode,
435 subprocess_tape_path,
436 #[cfg(feature = "testbench-wasi")]
437 _wasi_toolchain: wasi_guard,
438 saved_egress_env,
439 })
440 }
441
442 pub fn subprocess_mode(&self) -> ProcessTapeMode {
444 self.subprocess_mode
445 }
446
447 pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
450 self.subprocess_tape_path.as_deref()
451 }
452
453 pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
455 self.overlay.as_ref()
456 }
457
458 pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
460 self.process_tape.as_ref()
461 }
462
463 pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
465 self.recorder.as_ref()
466 }
467
468 pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
473 let diff = self
474 .overlay
475 .as_ref()
476 .map(|overlay| overlay.diff())
477 .unwrap_or_default();
478 let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
479 if let (Some(tape), Some(path)) = (
480 self.process_tape.as_ref(),
481 self.subprocess_tape_path.as_ref(),
482 ) {
483 tape.persist(path).map_err(TestbenchError::Subprocess)?;
484 }
485 self.process_tape
486 .as_ref()
487 .map(|tape| tape.recorded())
488 .unwrap_or_default()
489 } else {
490 Vec::new()
491 };
492 let mut emitted_tape = None;
493 if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
494 let header = TapeHeader::current(
495 self.tape_started_at_unix_ms,
496 self.tape_script_path.clone(),
497 self.tape_argv.clone(),
498 );
499 let tape = recorder.snapshot(header);
500 tape.persist(path).map_err(TestbenchError::Tape)?;
501 emitted_tape = Some(EmittedTape {
502 path: path.clone(),
503 records: tape.records.len(),
504 });
505 }
506 let clock_leaks = leak_audit::drain();
511 Ok(TestbenchFinalize {
513 fs_diff: diff,
514 recorded_subprocesses: recorded,
515 tape: emitted_tape,
516 clock_leaks,
517 })
518 }
519}
520
521impl Drop for TestbenchSession {
522 fn drop(&mut self) {
523 if let Some(saved) = self.saved_egress_env.take() {
524 restore_env("HARN_EGRESS_DEFAULT", saved.default);
525 restore_env("HARN_EGRESS_ALLOW", saved.allow);
526 restore_env("HARN_EGRESS_DENY", saved.deny);
527 reset_egress_policy_for_host();
528 }
529 }
532}
533
534fn restore_env(key: &str, prior: Option<String>) {
535 match prior {
536 Some(value) => std::env::set_var(key, value),
537 None => std::env::remove_var(key),
538 }
539}
540
541#[derive(Debug, Default, Clone)]
544pub struct TestbenchFinalize {
545 pub fs_diff: Vec<overlay_fs::DiffEntry>,
546 pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
547 pub tape: Option<EmittedTape>,
548 pub clock_leaks: Vec<ClockLeak>,
553}
554
555#[derive(Debug, Clone)]
557pub struct EmittedTape {
558 pub path: PathBuf,
559 pub records: usize,
560}
561
562#[derive(Debug)]
564pub enum TestbenchError {
565 Subprocess(String),
566 Tape(String),
567}
568
569impl std::fmt::Display for TestbenchError {
570 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571 match self {
572 Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
573 Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
574 }
575 }
576}
577
578impl std::error::Error for TestbenchError {}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn serial<F: FnOnce()>(body: F) {
590 let _guard = leak_audit::TEST_LOCK
591 .lock()
592 .unwrap_or_else(|p| p.into_inner());
593 body();
594 }
595
596 #[test]
597 fn paused_clock_pins_now_ms_for_session_lifetime() {
598 serial(|| {
599 let bench = Testbench::builder()
600 .paused_clock_at_ms(1_700_000_000_000)
601 .build();
602 let session = bench.activate().expect("activate");
603 assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
604 crate::clock_mock::advance(std::time::Duration::from_secs(60));
605 assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
606 drop(session);
607 assert!(!crate::clock_mock::is_mocked());
609 });
610 }
611
612 #[test]
613 fn deny_by_default_blocks_egress_until_drop() {
614 serial(|| {
615 let bench = Testbench::builder().deny_network().build();
616 let session = bench.activate().expect("activate");
617 assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
618 drop(session);
619 assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
620 });
621 }
622
623 #[test]
624 fn finalize_surfaces_clock_leaks_for_contrived_capability() {
625 serial(|| {
626 let bench = Testbench::builder()
627 .paused_clock_at_ms(1_700_000_000_000)
628 .build();
629 let session = bench.activate().expect("activate");
630
631 let _ = leak_audit::wall_now("test/contrived_leak");
635 let _ = leak_audit::instant_now("test/contrived_instant");
636 let _ = leak_audit::wall_now("test/contrived_leak");
637
638 let finalize = session.finalize().expect("finalize");
639 let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
640 .clock_leaks
641 .iter()
642 .map(|leak| (leak.capability_id.as_str(), leak))
643 .collect();
644 let wall = by_id
645 .get("test/contrived_leak")
646 .expect("wall leak surfaced");
647 assert_eq!(wall.count, 2);
648 let inst = by_id
649 .get("test/contrived_instant")
650 .expect("instant leak surfaced");
651 assert_eq!(inst.count, 1);
652
653 let next_session = Testbench::builder()
655 .paused_clock_at_ms(1_700_000_000_000)
656 .build()
657 .activate()
658 .expect("activate next");
659 let next = next_session.finalize().expect("finalize next");
660 assert!(next.clock_leaks.is_empty());
661 });
662 }
663
664 #[test]
665 fn audit_quiet_when_no_mock_is_active() {
666 serial(|| {
667 leak_audit::reset();
668 let _ = leak_audit::wall_now("test/no_mock");
671 let _ = leak_audit::instant_now("test/no_mock");
672 assert!(leak_audit::snapshot().is_empty());
673 });
674 }
675}