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 #[allow(clippy::no_effect_underscore_binding)]
324 let _llm_config = bench.llm;
325
326 #[cfg(feature = "testbench-wasi")]
327 let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
328
329 let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
330 match bench.subprocess {
331 SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
332 SubprocessConfig::Record { tape } => {
333 let active = Arc::new(ProcessTape::recording());
334 let guard = install_process_tape(Arc::clone(&active));
335 (
336 Some(Arc::clone(&active)),
337 Some(guard),
338 ProcessTapeMode::Record,
339 Some(tape),
340 )
341 }
342 SubprocessConfig::Replay { tape } => {
343 let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
344 let active = Arc::new(loaded);
345 let guard = install_process_tape(Arc::clone(&active));
346 (
347 Some(Arc::clone(&active)),
348 Some(guard),
349 ProcessTapeMode::Replay,
350 Some(tape),
351 )
352 }
353 #[cfg(feature = "testbench-wasi")]
354 SubprocessConfig::WasiToolchain { dir } => {
355 if !dir.exists() {
356 return Err(TestbenchError::Subprocess(format!(
357 "wasi toolchain directory does not exist: {}",
358 dir.display()
359 )));
360 }
361 wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
362 (None, None, ProcessTapeMode::Replay, None)
363 }
364 #[cfg(not(feature = "testbench-wasi"))]
365 SubprocessConfig::WasiToolchain { .. } => {
366 return Err(TestbenchError::Subprocess(
367 "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
368 ));
369 }
370 };
371
372 let (overlay, overlay_guard) = match bench.filesystem {
373 FilesystemConfig::Real => (None, None),
374 FilesystemConfig::Overlay { worktree } => {
375 let overlay = Arc::new(OverlayFs::rooted_at(worktree));
376 let guard = install_overlay(Arc::clone(&overlay));
377 (Some(overlay), Some(guard))
378 }
379 };
380
381 let saved_egress_env = match bench.network {
382 NetworkConfig::Real => None,
383 NetworkConfig::DenyByDefault { allow } => {
384 let saved = SavedEgressEnv {
385 default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
386 allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
387 deny: std::env::var("HARN_EGRESS_DENY").ok(),
388 };
389 reset_egress_policy_for_host();
393 std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
394 if allow.is_empty() {
395 std::env::remove_var("HARN_EGRESS_ALLOW");
396 } else {
397 std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
398 }
399 std::env::remove_var("HARN_EGRESS_DENY");
400 Some(saved)
401 }
402 };
403
404 let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
405 TapeConfig::Off => (None, None, None, Vec::new(), None),
406 TapeConfig::Emit {
407 path,
408 argv,
409 script_path,
410 } => {
411 let recorder = Arc::new(TapeRecorder::new());
412 let guard = install_recorder(Arc::clone(&recorder));
413 (
414 Some(Arc::clone(&recorder)),
415 Some(guard),
416 Some(path),
417 argv,
418 script_path,
419 )
420 }
421 };
422
423 Ok(Self {
424 _clock: clock_guard,
425 _process: process_guard,
426 _overlay: overlay_guard,
427 _recorder: recorder_guard,
428 process_tape,
429 overlay,
430 recorder,
431 tape_path,
432 tape_started_at_unix_ms: started_at_unix_ms,
433 tape_script_path,
434 tape_argv,
435 subprocess_mode,
436 subprocess_tape_path,
437 #[cfg(feature = "testbench-wasi")]
438 _wasi_toolchain: wasi_guard,
439 saved_egress_env,
440 })
441 }
442
443 pub fn subprocess_mode(&self) -> ProcessTapeMode {
445 self.subprocess_mode
446 }
447
448 pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
451 self.subprocess_tape_path.as_deref()
452 }
453
454 pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
456 self.overlay.as_ref()
457 }
458
459 pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
461 self.process_tape.as_ref()
462 }
463
464 pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
466 self.recorder.as_ref()
467 }
468
469 pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
474 let diff = self
475 .overlay
476 .as_ref()
477 .map(|overlay| overlay.diff())
478 .unwrap_or_default();
479 let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
480 if let (Some(tape), Some(path)) = (
481 self.process_tape.as_ref(),
482 self.subprocess_tape_path.as_ref(),
483 ) {
484 tape.persist(path).map_err(TestbenchError::Subprocess)?;
485 }
486 self.process_tape
487 .as_ref()
488 .map(|tape| tape.recorded())
489 .unwrap_or_default()
490 } else {
491 Vec::new()
492 };
493 let mut emitted_tape = None;
494 if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
495 let header = TapeHeader::current(
496 self.tape_started_at_unix_ms,
497 self.tape_script_path.clone(),
498 self.tape_argv.clone(),
499 );
500 let tape = recorder.snapshot(header);
501 tape.persist(path).map_err(TestbenchError::Tape)?;
502 emitted_tape = Some(EmittedTape {
503 path: path.clone(),
504 records: tape.records.len(),
505 });
506 }
507 let clock_leaks = leak_audit::drain();
512 Ok(TestbenchFinalize {
514 fs_diff: diff,
515 recorded_subprocesses: recorded,
516 tape: emitted_tape,
517 clock_leaks,
518 })
519 }
520}
521
522impl Drop for TestbenchSession {
523 fn drop(&mut self) {
524 if let Some(saved) = self.saved_egress_env.take() {
525 restore_env("HARN_EGRESS_DEFAULT", saved.default);
526 restore_env("HARN_EGRESS_ALLOW", saved.allow);
527 restore_env("HARN_EGRESS_DENY", saved.deny);
528 reset_egress_policy_for_host();
529 }
530 }
533}
534
535fn restore_env(key: &str, prior: Option<String>) {
536 match prior {
537 Some(value) => std::env::set_var(key, value),
538 None => std::env::remove_var(key),
539 }
540}
541
542#[derive(Debug, Default, Clone)]
545pub struct TestbenchFinalize {
546 pub fs_diff: Vec<overlay_fs::DiffEntry>,
547 pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
548 pub tape: Option<EmittedTape>,
549 pub clock_leaks: Vec<ClockLeak>,
554}
555
556#[derive(Debug, Clone)]
558pub struct EmittedTape {
559 pub path: PathBuf,
560 pub records: usize,
561}
562
563#[derive(Debug)]
565pub enum TestbenchError {
566 Subprocess(String),
567 Tape(String),
568}
569
570impl std::fmt::Display for TestbenchError {
571 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572 match self {
573 Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
574 Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
575 }
576 }
577}
578
579impl std::error::Error for TestbenchError {}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 fn serial<F: FnOnce()>(body: F) {
591 let _guard = leak_audit::TEST_LOCK
592 .lock()
593 .unwrap_or_else(|p| p.into_inner());
594 body();
595 }
596
597 #[test]
598 fn paused_clock_pins_now_ms_for_session_lifetime() {
599 serial(|| {
600 let bench = Testbench::builder()
601 .paused_clock_at_ms(1_700_000_000_000)
602 .build();
603 let session = bench.activate().expect("activate");
604 assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
605 crate::clock_mock::advance(std::time::Duration::from_mins(1));
606 assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
607 drop(session);
608 assert!(!crate::clock_mock::is_mocked());
610 });
611 }
612
613 #[test]
614 fn deny_by_default_blocks_egress_until_drop() {
615 serial(|| {
616 let bench = Testbench::builder().deny_network().build();
617 let session = bench.activate().expect("activate");
618 assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
619 drop(session);
620 assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
621 });
622 }
623
624 #[test]
625 fn finalize_surfaces_clock_leaks_for_contrived_capability() {
626 serial(|| {
627 let bench = Testbench::builder()
628 .paused_clock_at_ms(1_700_000_000_000)
629 .build();
630 let session = bench.activate().expect("activate");
631
632 let _ = leak_audit::wall_now("test/contrived_leak");
636 let _ = leak_audit::instant_now("test/contrived_instant");
637 let _ = leak_audit::wall_now("test/contrived_leak");
638
639 let finalize = session.finalize().expect("finalize");
640 let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
641 .clock_leaks
642 .iter()
643 .map(|leak| (leak.capability_id.as_str(), leak))
644 .collect();
645 let wall = by_id
646 .get("test/contrived_leak")
647 .expect("wall leak surfaced");
648 assert_eq!(wall.count, 2);
649 let inst = by_id
650 .get("test/contrived_instant")
651 .expect("instant leak surfaced");
652 assert_eq!(inst.count, 1);
653
654 let next_session = Testbench::builder()
656 .paused_clock_at_ms(1_700_000_000_000)
657 .build()
658 .activate()
659 .expect("activate next");
660 let next = next_session.finalize().expect("finalize next");
661 assert!(next.clock_leaks.is_empty());
662 });
663 }
664
665 #[test]
666 fn audit_quiet_when_no_mock_is_active() {
667 serial(|| {
668 leak_audit::reset();
669 let _ = leak_audit::wall_now("test/no_mock");
672 let _ = leak_audit::instant_now("test/no_mock");
673 assert!(leak_audit::snapshot().is_empty());
674 });
675 }
676}