Skip to main content

eryx/
sandbox.rs

1//! Sandboxed Python execution environment.
2
3use std::{
4    collections::HashMap,
5    marker::PhantomData,
6    path::PathBuf,
7    sync::{Arc, Mutex},
8    time::{Duration, Instant},
9};
10
11use tokio::sync::{mpsc, oneshot};
12use tokio_util::sync::CancellationToken;
13
14use crate::replay::{CallbackJournal, ReplayState, SuspendedCallback, wrap_callbacks};
15
16#[cfg(feature = "native-extensions")]
17use crate::cache::ComponentCache;
18
19/// Marker types for compile-time builder state tracking.
20///
21/// These types are used with [`SandboxBuilder`] to ensure at compile time
22/// that all required configuration is provided before building a sandbox.
23pub mod state {
24    /// Marker indicating a required component has not been configured.
25    #[derive(Debug, Clone, Copy, Default)]
26    pub struct Needs;
27
28    /// Marker indicating a required component has been configured.
29    #[derive(Debug, Clone, Copy, Default)]
30    pub struct Has;
31}
32
33/// Try to automatically find the Python stdlib directory.
34///
35/// This function searches multiple locations in order:
36/// 1. `ERYX_PYTHON_STDLIB` environment variable
37/// 2. `./python-stdlib` (relative to current directory)
38/// 3. `<exe_dir>/python-stdlib` (relative to executable)
39/// 4. `<exe_dir>/../python-stdlib` (sibling of executable directory)
40///
41/// Returns `Some(path)` if a valid stdlib directory is found, `None` otherwise.
42/// A valid stdlib directory must exist and contain an `encodings` subdirectory
43/// (required for Python initialization).
44fn find_python_stdlib() -> Option<PathBuf> {
45    // Helper to validate a stdlib directory
46    fn is_valid_stdlib(path: &std::path::Path) -> bool {
47        path.is_dir() && path.join("encodings").is_dir()
48    }
49
50    // 1. Environment variable
51    if let Ok(path) = std::env::var("ERYX_PYTHON_STDLIB") {
52        let path = PathBuf::from(path);
53        if is_valid_stdlib(&path) {
54            tracing::debug!(path = %path.display(), "Found Python stdlib via ERYX_PYTHON_STDLIB");
55            return Some(path);
56        }
57        tracing::warn!(
58            path = %path.display(),
59            "ERYX_PYTHON_STDLIB is set but path is not a valid stdlib directory"
60        );
61    }
62
63    // 2. Current directory
64    let cwd_stdlib = PathBuf::from("python-stdlib");
65    if is_valid_stdlib(&cwd_stdlib)
66        && let Ok(abs_path) = cwd_stdlib.canonicalize()
67    {
68        tracing::debug!(path = %abs_path.display(), "Found Python stdlib in current directory");
69        return Some(abs_path);
70    }
71
72    // 3. Relative to executable
73    if let Ok(exe_path) = std::env::current_exe()
74        && let Some(exe_dir) = exe_path.parent()
75    {
76        // Try <exe_dir>/python-stdlib
77        let exe_stdlib = exe_dir.join("python-stdlib");
78        if is_valid_stdlib(&exe_stdlib) {
79            tracing::debug!(path = %exe_stdlib.display(), "Found Python stdlib relative to executable");
80            return Some(exe_stdlib);
81        }
82
83        // 4. Try <exe_dir>/../python-stdlib (common for installed binaries)
84        let parent_stdlib = exe_dir.join("..").join("python-stdlib");
85        if is_valid_stdlib(&parent_stdlib)
86            && let Ok(abs_path) = parent_stdlib.canonicalize()
87        {
88            tracing::debug!(path = %abs_path.display(), "Found Python stdlib in parent of executable");
89            return Some(abs_path);
90        }
91    }
92
93    None
94}
95use crate::callback::Callback;
96use crate::callback_handler::{
97    run_callback_handler, run_net_handler, run_output_collector, run_trace_collector,
98};
99use crate::error::Error;
100use crate::library::RuntimeLibrary;
101use crate::net::{ConnectionManager, NetConfig};
102use crate::trace::{OutputHandler, TraceEvent, TraceHandler};
103use crate::wasm::{CallbackRequest, NetRequest, OutputRequest, PythonExecutor, TraceRequest};
104
105/// A sandboxed Python execution environment.
106pub struct Sandbox {
107    /// The Python WASM executor (wrapped in Arc for sharing with sessions).
108    executor: Arc<PythonExecutor>,
109    /// Registered callbacks that Python code can invoke (wrapped in Arc to avoid cloning the map on each execute).
110    callbacks: Arc<HashMap<String, Arc<dyn Callback>>>,
111    /// Python preamble code injected before user code.
112    preamble: String,
113    /// Combined type stubs from all libraries.
114    type_stubs: String,
115    /// Handler for execution trace events.
116    trace_handler: Option<Arc<dyn TraceHandler>>,
117    /// Whether trace events are collected in [`ExecuteResult::trace`].
118    collect_trace: bool,
119    /// Handler for streaming stdout output.
120    output_handler: Option<Arc<dyn OutputHandler>>,
121    /// Resource limits for execution.
122    resource_limits: ResourceLimits,
123    /// Network configuration for TLS connections.
124    net_config: Option<NetConfig>,
125    /// Secrets configuration (name -> SecretConfig).
126    secrets: HashMap<String, crate::secrets::SecretConfig>,
127    /// Stdout scrubbing policy.
128    scrub_stdout: crate::secrets::OutputScrubPolicy,
129    /// Stderr scrubbing policy.
130    scrub_stderr: crate::secrets::OutputScrubPolicy,
131    /// Whether to scrub secret placeholders from the structured `result` (and
132    /// `result_error`) channel. Defaults to `false`: unlike stdout/stderr, the
133    /// result is a programmatic side channel, so scrubbing is opt-in.
134    scrub_result: bool,
135    /// File scrubbing policy for VFS integration.
136    scrub_files: crate::secrets::FileScrubPolicy,
137    /// Host filesystem volume mounts.
138    #[cfg(feature = "vfs")]
139    volumes: Vec<crate::session::VolumeMount>,
140    /// Per-request VFS storage override (set via pool's `with_vfs_storage`).
141    /// When set, this replaces the default scrubbing storage in `execute()`.
142    #[cfg(feature = "vfs")]
143    vfs_storage: Option<std::sync::Arc<dyn eryx_vfs::VfsStorage>>,
144    /// Extracted packages (kept alive to prevent temp directory cleanup).
145    _packages: Vec<crate::package::ExtractedPackage>,
146    /// Previous callback journal to replay from, set via
147    /// [`SandboxBuilder::with_replay_journal`]. Used by
148    /// [`Sandbox::execute_with_journal`].
149    replay_journal: Option<CallbackJournal>,
150}
151
152impl std::fmt::Debug for Sandbox {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        let mut debug = f.debug_struct("Sandbox");
155        debug
156            .field(
157                "callbacks",
158                &format!("[{} callbacks]", self.callbacks.len()),
159            )
160            .field("preamble_len", &self.preamble.len())
161            .field("type_stubs_len", &self.type_stubs.len())
162            .field("has_trace_handler", &self.trace_handler.is_some())
163            .field("collect_trace", &self.collect_trace)
164            .field("has_output_handler", &self.output_handler.is_some())
165            .field("resource_limits", &self.resource_limits)
166            .field("has_net_config", &self.net_config.is_some());
167        debug.finish_non_exhaustive()
168    }
169}
170
171impl Sandbox {
172    /// Create a sandbox builder.
173    ///
174    /// You must configure both a runtime source and Python stdlib before building.
175    ///
176    /// # Example
177    ///
178    /// ```rust,ignore
179    /// let sandbox = Sandbox::builder()
180    ///     .with_wasm_file("runtime.wasm")
181    ///     .with_python_stdlib("/path/to/stdlib")
182    ///     .build()?;
183    /// ```
184    ///
185    /// Or use [`Sandbox::embedded()`] for zero-config setup when the `embedded`
186    /// feature is enabled.
187    #[must_use]
188    pub fn builder() -> SandboxBuilder<state::Needs, state::Needs> {
189        SandboxBuilder::new()
190    }
191
192    /// Create a sandbox builder with embedded runtime and stdlib.
193    ///
194    /// This is the simplest way to create a sandbox - no configuration required.
195    /// Only available when the `embedded` feature is enabled.
196    ///
197    /// # Example
198    ///
199    /// ```rust,ignore
200    /// let sandbox = Sandbox::embedded()
201    ///     .with_callback(MyCallback)
202    ///     .build()?;
203    /// ```
204    #[cfg(feature = "embedded")]
205    #[must_use]
206    pub fn embedded() -> SandboxBuilder<state::Has, state::Has> {
207        SandboxBuilder::new_embedded()
208    }
209
210    /// Execute Python code in the sandbox.
211    ///
212    /// If an `OutputHandler` was configured, stdout is streamed to it during execution.
213    /// If a `TraceHandler` was configured, trace events are emitted during execution.
214    ///
215    /// Returns the final result including complete stdout and collected trace events.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the Python code fails to execute or a resource limit is exceeded.
220    pub async fn execute(&self, code: &str) -> Result<ExecuteResult, Error> {
221        self.run_inner(code, None).await
222    }
223
224    /// Execute Python code with callback-result replay and journaling.
225    ///
226    /// This behaves like [`execute`](Self::execute) but additionally records a
227    /// [`CallbackJournal`] of every callback invocation, and — if a previous
228    /// journal was configured via
229    /// [`SandboxBuilder::with_replay_journal`] — replays matching callbacks from
230    /// that journal instead of invoking them live. See the
231    /// [`replay`](crate::replay) module for the full model.
232    ///
233    /// The returned [`ReplayOutcome`] always carries the freshly-recorded
234    /// journal, even when execution fails, so a later resubmission can replay
235    /// everything that completed.
236    ///
237    /// Each call uses fresh replay state, so a sandbox may be executed
238    /// repeatedly without the journal cursor leaking between runs.
239    ///
240    /// # Security
241    ///
242    /// Replayed journal entries are returned to Python verbatim — the callback
243    /// is not re-executed. A crafted journal can therefore inject arbitrary
244    /// values into the script. **Only replay journals produced by a trusted
245    /// source** (e.g. a previous run of the same sandbox, or a journal verified
246    /// via HMAC signature). See the [`replay`](crate::replay) module docs for
247    /// details.
248    pub async fn execute_with_journal(&self, code: &str) -> ReplayOutcome {
249        let previous = self
250            .replay_journal
251            .clone()
252            .unwrap_or_else(|| CallbackJournal::new(code));
253        let state = Arc::new(Mutex::new(ReplayState::new(previous)));
254
255        let result = self.run_inner(code, Some(Arc::clone(&state))).await;
256
257        let guard = state
258            .lock()
259            .unwrap_or_else(std::sync::PoisonError::into_inner);
260        ReplayOutcome {
261            journal: guard.build_journal(code),
262            replayed_callbacks: guard.replayed_count(),
263            suspended: guard.suspended().cloned(),
264            result,
265        }
266    }
267
268    /// Shared execution body for [`execute`](Self::execute) and
269    /// [`execute_with_journal`](Self::execute_with_journal).
270    ///
271    /// When `replay_state` is `Some`, every registered callback is wrapped with
272    /// a [`ReplayCallback`](crate::replay::ReplayCallback) sharing that state.
273    #[tracing::instrument(
274        skip(self, code, replay_state),
275        fields(
276            code_len = code.len(),
277            callbacks = self.callbacks.len(),
278            has_trace_handler = self.trace_handler.is_some(),
279            replay = replay_state.is_some(),
280            timeout = ?self.resource_limits.execution_timeout,
281            fuel_limit = ?self.resource_limits.max_fuel,
282        )
283    )]
284    async fn run_inner(
285        &self,
286        code: &str,
287        replay_state: Option<Arc<Mutex<ReplayState>>>,
288    ) -> Result<ExecuteResult, Error> {
289        let start = Instant::now();
290
291        // Prepend preamble to user code if present
292        let full_code = if self.preamble.is_empty() {
293            code.to_string()
294        } else {
295            format!("{}\n\n# User code\n{}", self.preamble, code)
296        };
297
298        // Create channel for callback requests
299        let (callback_tx, callback_rx) = mpsc::channel::<CallbackRequest>(32);
300
301        // Select callbacks: wrap each with a replay wrapper when journaling/replay
302        // is enabled, otherwise use the registered callbacks directly.
303        let active_callbacks: Arc<HashMap<String, Arc<dyn Callback>>> = match &replay_state {
304            Some(state) => Arc::new(wrap_callbacks(&self.callbacks, state)),
305            None => Arc::clone(&self.callbacks),
306        };
307
308        // Collect callbacks as a Vec for the executor
309        let callbacks: Vec<Arc<dyn Callback>> = active_callbacks.values().cloned().collect();
310
311        // Spawn task to handle callback requests concurrently (Arc clone is cheap)
312        let callbacks_arc = Arc::clone(&active_callbacks);
313        let resource_limits = self.resource_limits.clone();
314        let secrets_arc = Arc::new(self.secrets.clone());
315        let callback_secrets = Arc::clone(&secrets_arc);
316        let callback_handler = tokio::spawn(async move {
317            run_callback_handler(
318                callback_rx,
319                callbacks_arc,
320                resource_limits,
321                callback_secrets,
322            )
323            .await
324        });
325
326        // Create the trace channel and collector only when tracing is enabled.
327        let tracing_enabled = self.tracing_enabled();
328        let (trace_tx, trace_collector) = if tracing_enabled {
329            let (trace_tx, trace_rx) = mpsc::unbounded_channel::<TraceRequest>();
330            let trace_handler = self.trace_handler.clone();
331            let collect_trace = self.collect_trace;
332            let trace_secrets = self.secrets.clone();
333            let trace_collector = tokio::spawn(async move {
334                run_trace_collector(trace_rx, trace_handler, collect_trace, trace_secrets).await
335            });
336            (Some(trace_tx), Some(trace_collector))
337        } else {
338            (None, None)
339        };
340
341        // Spawn network handler if networking is enabled
342        let (net_tx, net_handler) = if let Some(ref config) = self.net_config {
343            let (tx, rx) = mpsc::channel::<NetRequest>(32);
344            let manager = ConnectionManager::new(config.clone(), self.secrets.clone());
345            let handler = tokio::spawn(async move { run_net_handler(rx, manager).await });
346            (Some(tx), Some(handler))
347        } else {
348            (None, None)
349        };
350
351        // Spawn output collector for real-time streaming if handler is configured
352        let (output_tx, output_handler_task) = if self.output_handler.is_some() {
353            let (tx, rx) = mpsc::unbounded_channel::<OutputRequest>();
354            let handler = self.output_handler.clone();
355            let output_secrets = self.secrets.clone();
356            let scrub_stdout = matches!(self.scrub_stdout, crate::secrets::OutputScrubPolicy::All);
357            let scrub_stderr = matches!(self.scrub_stderr, crate::secrets::OutputScrubPolicy::All);
358            let task = tokio::spawn(async move {
359                run_output_collector(rx, handler, output_secrets, scrub_stdout, scrub_stderr).await
360            });
361            (Some(tx), Some(task))
362        } else {
363            (None, None)
364        };
365
366        // Execute the Python code using the builder API. A configured handler
367        // always enables tracing, even if result collection was disabled.
368        let mut execute_builder = self
369            .executor
370            .execute(&full_code)
371            .with_callbacks(&callbacks, callback_tx);
372        if let Some(trace_tx) = trace_tx {
373            execute_builder = execute_builder.with_tracing(trace_tx);
374        }
375
376        // Add output streaming if handler is configured
377        if let Some(tx) = output_tx {
378            execute_builder = execute_builder.with_output_streaming(tx);
379        }
380
381        // Add network channel if networking is enabled
382        if let Some(tx) = net_tx {
383            execute_builder = execute_builder.with_network(tx);
384        }
385
386        // Add VFS storage — use per-request override if set, otherwise create
387        // a scrubbing storage from the sandbox's secrets configuration.
388        #[cfg(feature = "vfs")]
389        {
390            let vfs_max_bytes = self
391                .resource_limits
392                .max_vfs_bytes
393                .unwrap_or(eryx_vfs::DEFAULT_MAX_BYTES);
394            let vfs_storage = if let Some(ref storage) = self.vfs_storage {
395                eryx_vfs::ArcStorage::new(Arc::clone(storage))
396            } else {
397                let vfs_secrets = self
398                    .secrets
399                    .iter()
400                    .map(|(k, v)| {
401                        (
402                            k.clone(),
403                            eryx_vfs::VfsSecretConfig {
404                                placeholder: v.placeholder.clone(),
405                            },
406                        )
407                    })
408                    .collect();
409                let vfs_policy = match &self.scrub_files {
410                    crate::secrets::FileScrubPolicy::All => eryx_vfs::VfsFileScrubPolicy::All,
411                    crate::secrets::FileScrubPolicy::None => eryx_vfs::VfsFileScrubPolicy::None,
412                    crate::secrets::FileScrubPolicy::Except(paths) => {
413                        eryx_vfs::VfsFileScrubPolicy::Except(paths.clone())
414                    }
415                    crate::secrets::FileScrubPolicy::Only(paths) => {
416                        eryx_vfs::VfsFileScrubPolicy::Only(paths.clone())
417                    }
418                };
419                let scrubbing_storage = eryx_vfs::ScrubbingStorage::new(
420                    eryx_vfs::InMemoryStorage::with_max_bytes(vfs_max_bytes),
421                    vfs_secrets,
422                    vfs_policy,
423                );
424                eryx_vfs::ArcStorage::new(std::sync::Arc::new(scrubbing_storage))
425            };
426            execute_builder = execute_builder.with_vfs_storage(vfs_storage);
427        }
428
429        // Add volume mounts if configured
430        #[cfg(feature = "vfs")]
431        if !self.volumes.is_empty() {
432            execute_builder = execute_builder.with_volumes(self.volumes.clone());
433        }
434
435        // Add memory limit if configured
436        if let Some(limit) = self.resource_limits.max_memory_bytes {
437            execute_builder = execute_builder.with_memory_limit(limit);
438        }
439
440        // Add timeout if configured
441        if let Some(timeout) = self.resource_limits.execution_timeout {
442            execute_builder = execute_builder.with_timeout(timeout);
443        }
444
445        // Add fuel limit if configured
446        if let Some(fuel) = self.resource_limits.max_fuel {
447            execute_builder = execute_builder.with_fuel_limit(fuel);
448        }
449
450        let execution_result = execute_builder.run().await;
451
452        // Wait for the handler tasks to complete
453        // The callback channel is closed when execute_future completes (callback_tx dropped)
454        let callback_invocations = callback_handler.await.unwrap_or(0);
455        let trace_events = match trace_collector {
456            Some(trace_collector) => trace_collector.await.unwrap_or_default(),
457            None => Vec::new(),
458        };
459
460        // Network handler completes when its channel is dropped (execute_builder dropped)
461        if let Some(handler) = net_handler {
462            let _ = handler.await;
463        }
464
465        // Output handler completes when its channel is dropped (execute_builder dropped)
466        if let Some(handler) = output_handler_task {
467            let _ = handler.await;
468        }
469
470        let duration = start.elapsed();
471
472        match execution_result {
473            Ok(output) => {
474                // Scrub secret placeholders from output based on policy
475                let stdout = if matches!(self.scrub_stdout, crate::secrets::OutputScrubPolicy::All)
476                {
477                    crate::secrets::scrub_placeholders(&output.stdout, &self.secrets)
478                } else {
479                    output.stdout
480                };
481
482                let stderr = if matches!(self.scrub_stderr, crate::secrets::OutputScrubPolicy::All)
483                {
484                    crate::secrets::scrub_placeholders(&output.stderr, &self.secrets)
485                } else {
486                    output.stderr
487                };
488
489                // The structured result is an output channel too, but scrubbing is
490                // opt-in (it's a programmatic side channel). When enabled, scrub both
491                // the result and its error message.
492                let (result, result_error) = if self.scrub_result {
493                    (
494                        output
495                            .result
496                            .map(|r| crate::secrets::scrub_placeholders(&r, &self.secrets)),
497                        output
498                            .result_error
499                            .map(|e| crate::secrets::scrub_placeholders(&e, &self.secrets)),
500                    )
501                } else {
502                    (output.result, output.result_error)
503                };
504
505                tracing::info!(
506                    duration_ms = duration.as_millis() as u64,
507                    callback_invocations,
508                    peak_memory_bytes = output.peak_memory_bytes,
509                    fuel_consumed = ?output.fuel_consumed,
510                    "Sandbox execution completed"
511                );
512
513                Ok(ExecuteResult {
514                    stdout,
515                    stderr,
516                    trace: trace_events,
517                    result,
518                    result_error,
519                    stats: ExecuteStats {
520                        duration,
521                        callback_invocations,
522                        peak_memory_bytes: Some(output.peak_memory_bytes),
523                        fuel_consumed: output.fuel_consumed,
524                    },
525                })
526            }
527            Err(error) => Err(error),
528        }
529    }
530
531    /// Get combined type stubs for all loaded libraries.
532    /// Useful for including in LLM context windows.
533    #[must_use]
534    pub fn type_stubs(&self) -> &str {
535        &self.type_stubs
536    }
537
538    /// Get a reference to the registered callbacks.
539    #[must_use]
540    pub fn callbacks(&self) -> &HashMap<String, Arc<dyn Callback>> {
541        &self.callbacks
542    }
543
544    /// Get the callbacks as an Arc for efficient sharing.
545    ///
546    /// This is more efficient than `callbacks().clone()` when you need to
547    /// move callbacks into a spawned task, as it only clones the Arc pointer.
548    #[must_use]
549    pub(crate) fn callbacks_arc(&self) -> Arc<HashMap<String, Arc<dyn Callback>>> {
550        Arc::clone(&self.callbacks)
551    }
552
553    /// Get the Python preamble code.
554    #[must_use]
555    pub fn preamble(&self) -> &str {
556        &self.preamble
557    }
558
559    /// Get a reference to the trace handler.
560    #[must_use]
561    pub fn trace_handler(&self) -> &Option<Arc<dyn TraceHandler>> {
562        &self.trace_handler
563    }
564
565    /// Whether execution tracing is required for result collection or a handler.
566    #[must_use]
567    pub(crate) fn tracing_enabled(&self) -> bool {
568        self.collect_trace || self.trace_handler.is_some()
569    }
570
571    /// Whether trace events should be retained in [`ExecuteResult::trace`].
572    #[must_use]
573    pub(crate) const fn trace_collection_enabled(&self) -> bool {
574        self.collect_trace
575    }
576
577    /// Get a reference to the output handler.
578    #[must_use]
579    pub fn output_handler(&self) -> &Option<Arc<dyn OutputHandler>> {
580        &self.output_handler
581    }
582
583    /// Get a reference to the resource limits.
584    #[must_use]
585    pub fn resource_limits(&self) -> &ResourceLimits {
586        &self.resource_limits
587    }
588
589    /// Get a reference to the secrets configuration.
590    #[must_use]
591    pub(crate) fn secrets(&self) -> &HashMap<String, crate::secrets::SecretConfig> {
592        &self.secrets
593    }
594
595    /// Whether stdout should be scrubbed of secret placeholders.
596    #[must_use]
597    pub(crate) fn scrub_stdout(&self) -> bool {
598        matches!(self.scrub_stdout, crate::secrets::OutputScrubPolicy::All)
599    }
600
601    /// Whether stderr should be scrubbed of secret placeholders.
602    #[must_use]
603    pub(crate) fn scrub_stderr(&self) -> bool {
604        matches!(self.scrub_stderr, crate::secrets::OutputScrubPolicy::All)
605    }
606
607    /// Whether the structured `result` channel should be scrubbed of secret
608    /// placeholders. Opt-in (defaults to `false`).
609    #[must_use]
610    pub(crate) fn scrub_result(&self) -> bool {
611        self.scrub_result
612    }
613
614    /// Get a reference to the Python executor.
615    ///
616    /// This allows creating a `SessionExecutor` from a pooled sandbox,
617    /// enabling state persistence between executions.
618    #[must_use]
619    pub fn executor(&self) -> Arc<PythonExecutor> {
620        self.executor.clone()
621    }
622
623    /// Set the registered callbacks (replacing any existing ones).
624    ///
625    /// Used by the pool to configure per-request callbacks on a reused sandbox.
626    pub(crate) fn set_callbacks(&mut self, callbacks: Vec<Box<dyn Callback>>) {
627        let mut map = HashMap::new();
628        for callback in callbacks {
629            map.insert(callback.name().to_string(), Arc::from(callback));
630        }
631        self.callbacks = Arc::new(map);
632    }
633
634    /// Set the trace handler for execution events.
635    ///
636    /// Used by the pool to configure per-request tracing on a reused sandbox.
637    pub(crate) fn set_trace_handler(&mut self, handler: impl TraceHandler + 'static) {
638        self.trace_handler = Some(Arc::new(handler));
639    }
640
641    /// Set the output handler for streaming stdout/stderr.
642    ///
643    /// Used by the pool to configure per-request output streaming on a reused sandbox.
644    pub(crate) fn set_output_handler(&mut self, handler: impl OutputHandler + 'static) {
645        self.output_handler = Some(Arc::new(handler));
646    }
647
648    /// Set resource limits for execution.
649    ///
650    /// Used by the pool to configure per-request limits on a reused sandbox.
651    pub(crate) fn set_resource_limits(&mut self, limits: ResourceLimits) {
652        self.resource_limits = limits;
653    }
654
655    /// Set VFS storage for this request.
656    ///
657    /// When set, this replaces the default scrubbing storage created in `execute()`.
658    /// Used by the pool to inject pre-populated VFS storage (e.g., supporting files).
659    #[cfg(feature = "vfs")]
660    pub(crate) fn set_vfs_storage(&mut self, storage: std::sync::Arc<dyn eryx_vfs::VfsStorage>) {
661        self.vfs_storage = Some(storage);
662    }
663
664    /// Clear per-request state so the sandbox can be returned to the pool.
665    ///
666    /// Resets callbacks, handlers, and resource limits to defaults while
667    /// preserving the executor and other long-lived state.
668    pub(crate) fn clear_per_request_state(&mut self) {
669        self.callbacks = Arc::new(HashMap::new());
670        self.trace_handler = None;
671        self.output_handler = None;
672        self.resource_limits = ResourceLimits::default();
673        #[cfg(feature = "vfs")]
674        {
675            self.vfs_storage = None;
676        }
677    }
678
679    /// Execute Python code with cancellation support.
680    ///
681    /// Returns an [`ExecutionHandle`] that can be used to cancel the execution
682    /// or wait for its completion.
683    ///
684    /// # Example
685    ///
686    /// ```rust,ignore
687    /// let handle = sandbox.execute_cancellable("while True: pass").await?;
688    ///
689    /// // Cancel after 5 seconds from another task
690    /// let cancel_handle = handle.clone();
691    /// tokio::spawn(async move {
692    ///     tokio::time::sleep(Duration::from_secs(5)).await;
693    ///     cancel_handle.cancel();
694    /// });
695    ///
696    /// // Wait for result
697    /// match handle.wait().await {
698    ///     Ok(result) => println!("Completed: {}", result.stdout),
699    ///     Err(Error::Cancelled) => println!("Cancelled"),
700    ///     Err(e) => println!("Error: {e}"),
701    /// }
702    /// ```
703    ///
704    /// # Errors
705    ///
706    /// Returns an error if the execution cannot be started.
707    #[tracing::instrument(
708        skip(self, code),
709        fields(code_len = code.len())
710    )]
711    pub fn execute_cancellable(&self, code: &str) -> ExecutionHandle {
712        let cancel_token = CancellationToken::new();
713        let (result_tx, result_rx) = oneshot::channel();
714
715        // Clone what we need for the spawned task
716        let executor = Arc::clone(&self.executor);
717        let callbacks = Arc::clone(&self.callbacks);
718        let preamble = self.preamble.clone();
719        let trace_handler = self.trace_handler.clone();
720        let collect_trace = self.collect_trace;
721        let tracing_enabled = self.tracing_enabled();
722        let output_handler = self.output_handler.clone();
723        let resource_limits = self.resource_limits.clone();
724        let net_config = self.net_config.clone();
725        let secrets = self.secrets.clone();
726        let scrub_stdout = self.scrub_stdout.clone();
727        let scrub_stderr = self.scrub_stderr.clone();
728        let scrub_result = self.scrub_result;
729        let scrub_files = self.scrub_files.clone();
730        #[cfg(feature = "vfs")]
731        let volumes = self.volumes.clone();
732        #[cfg(feature = "vfs")]
733        let vfs_storage = self.vfs_storage.clone();
734        let code = code.to_string();
735        let token = cancel_token.clone();
736
737        // Spawn the execution task
738        tokio::spawn(async move {
739            let result = Self::execute_with_cancellation(
740                executor,
741                callbacks,
742                &preamble,
743                trace_handler,
744                collect_trace,
745                tracing_enabled,
746                output_handler,
747                resource_limits,
748                net_config,
749                secrets,
750                scrub_stdout,
751                scrub_stderr,
752                scrub_result,
753                scrub_files,
754                #[cfg(feature = "vfs")]
755                volumes,
756                #[cfg(feature = "vfs")]
757                vfs_storage,
758                &code,
759                token,
760            )
761            .await;
762
763            // Send result back (ignore error if receiver dropped)
764            let _ = result_tx.send(result);
765        });
766
767        ExecutionHandle {
768            cancel_token,
769            result: result_rx,
770        }
771    }
772
773    /// Internal execution with cancellation support.
774    #[allow(clippy::too_many_arguments, unused_variables)]
775    async fn execute_with_cancellation(
776        executor: Arc<PythonExecutor>,
777        callbacks: Arc<HashMap<String, Arc<dyn Callback>>>,
778        preamble: &str,
779        trace_handler: Option<Arc<dyn TraceHandler>>,
780        collect_trace: bool,
781        tracing_enabled: bool,
782        output_handler: Option<Arc<dyn OutputHandler>>,
783        resource_limits: ResourceLimits,
784        net_config: Option<NetConfig>,
785        secrets: HashMap<String, crate::secrets::SecretConfig>,
786        scrub_stdout: crate::secrets::OutputScrubPolicy,
787        scrub_stderr: crate::secrets::OutputScrubPolicy,
788        scrub_result: bool,
789        scrub_files: crate::secrets::FileScrubPolicy,
790        #[cfg(feature = "vfs")] volumes: Vec<crate::session::VolumeMount>,
791        #[cfg(feature = "vfs")] vfs_storage_override: Option<
792            std::sync::Arc<dyn eryx_vfs::VfsStorage>,
793        >,
794        code: &str,
795        cancel_token: CancellationToken,
796    ) -> Result<ExecuteResult, Error> {
797        let start = Instant::now();
798
799        // Prepend preamble to user code if present
800        let full_code = if preamble.is_empty() {
801            code.to_string()
802        } else {
803            format!(
804                "{}
805
806# User code
807{}",
808                preamble, code
809            )
810        };
811
812        // Create channel for callback requests
813        let (callback_tx, callback_rx) = mpsc::channel::<CallbackRequest>(32);
814
815        // Collect callbacks as a Vec for the executor
816        let callbacks_vec: Vec<Arc<dyn Callback>> = callbacks.values().cloned().collect();
817
818        // Spawn task to handle callback requests concurrently
819        let callbacks_arc = Arc::clone(&callbacks);
820        let resource_limits_clone = resource_limits.clone();
821        let secrets_arc = Arc::new(secrets.clone());
822        let callback_secrets = Arc::clone(&secrets_arc);
823        let callback_handler = tokio::spawn(async move {
824            run_callback_handler(
825                callback_rx,
826                callbacks_arc,
827                resource_limits_clone,
828                callback_secrets,
829            )
830            .await
831        });
832
833        // Create the trace channel and collector only when tracing is enabled.
834        let (trace_tx, trace_collector) = if tracing_enabled {
835            let (trace_tx, trace_rx) = mpsc::unbounded_channel::<TraceRequest>();
836            let trace_handler = trace_handler.clone();
837            let trace_secrets = secrets.clone();
838            let trace_collector = tokio::spawn(async move {
839                run_trace_collector(trace_rx, trace_handler, collect_trace, trace_secrets).await
840            });
841            (Some(trace_tx), Some(trace_collector))
842        } else {
843            (None, None)
844        };
845
846        // Spawn output collector for real-time streaming if handler is configured
847        let (output_tx, output_handler_task) = if output_handler.is_some() {
848            let (tx, rx) = mpsc::unbounded_channel::<OutputRequest>();
849            let handler = output_handler.clone();
850            let output_secrets = secrets.clone();
851            let scrub_out = matches!(scrub_stdout, crate::secrets::OutputScrubPolicy::All);
852            let scrub_err = matches!(scrub_stderr, crate::secrets::OutputScrubPolicy::All);
853            let task = tokio::spawn(async move {
854                run_output_collector(rx, handler, output_secrets, scrub_out, scrub_err).await
855            });
856            (Some(tx), Some(task))
857        } else {
858            (None, None)
859        };
860
861        // Spawn network handler if networking is enabled
862        let (net_tx, net_handler) = if let Some(ref config) = net_config {
863            let (tx, rx) = mpsc::channel::<crate::wasm::NetRequest>(32);
864            let manager = ConnectionManager::new(config.clone(), secrets.clone());
865            let handler = tokio::spawn(async move { run_net_handler(rx, manager).await });
866            (Some(tx), Some(handler))
867        } else {
868            (None, None)
869        };
870
871        // Execute the Python code using the builder API with cancellation.
872        let mut execute_builder = executor
873            .execute(&full_code)
874            .with_callbacks(&callbacks_vec, callback_tx)
875            .with_cancellation(cancel_token.clone());
876        if let Some(trace_tx) = trace_tx {
877            execute_builder = execute_builder.with_tracing(trace_tx);
878        }
879
880        // Add output streaming channel if handler is configured
881        if let Some(tx) = output_tx {
882            execute_builder = execute_builder.with_output_streaming(tx);
883        }
884
885        // Add network channel if networking is enabled
886        if let Some(tx) = net_tx {
887            execute_builder = execute_builder.with_network(tx);
888        }
889
890        // Add VFS storage — use per-request override if set, otherwise create
891        // a scrubbing storage from the secrets configuration.
892        #[cfg(feature = "vfs")]
893        {
894            let vfs_storage = if let Some(storage) = vfs_storage_override {
895                eryx_vfs::ArcStorage::new(storage)
896            } else {
897                let vfs_secrets = secrets
898                    .iter()
899                    .map(|(k, v)| {
900                        (
901                            k.clone(),
902                            eryx_vfs::VfsSecretConfig {
903                                placeholder: v.placeholder.clone(),
904                            },
905                        )
906                    })
907                    .collect();
908                let vfs_policy = match &scrub_files {
909                    crate::secrets::FileScrubPolicy::All => eryx_vfs::VfsFileScrubPolicy::All,
910                    crate::secrets::FileScrubPolicy::None => eryx_vfs::VfsFileScrubPolicy::None,
911                    crate::secrets::FileScrubPolicy::Except(paths) => {
912                        eryx_vfs::VfsFileScrubPolicy::Except(paths.clone())
913                    }
914                    crate::secrets::FileScrubPolicy::Only(paths) => {
915                        eryx_vfs::VfsFileScrubPolicy::Only(paths.clone())
916                    }
917                };
918                let scrubbing_storage = eryx_vfs::ScrubbingStorage::new(
919                    eryx_vfs::InMemoryStorage::with_max_bytes(
920                        resource_limits
921                            .max_vfs_bytes
922                            .unwrap_or(eryx_vfs::DEFAULT_MAX_BYTES),
923                    ),
924                    vfs_secrets,
925                    vfs_policy,
926                );
927                eryx_vfs::ArcStorage::new(std::sync::Arc::new(scrubbing_storage))
928            };
929            execute_builder = execute_builder.with_vfs_storage(vfs_storage);
930        }
931
932        // Add volume mounts if configured
933        #[cfg(feature = "vfs")]
934        if !volumes.is_empty() {
935            execute_builder = execute_builder.with_volumes(volumes);
936        }
937
938        // Add memory limit if configured
939        if let Some(limit) = resource_limits.max_memory_bytes {
940            execute_builder = execute_builder.with_memory_limit(limit);
941        }
942
943        // Add timeout if configured
944        if let Some(timeout) = resource_limits.execution_timeout {
945            execute_builder = execute_builder.with_timeout(timeout);
946        }
947
948        // Add fuel limit if configured
949        if let Some(fuel) = resource_limits.max_fuel {
950            execute_builder = execute_builder.with_fuel_limit(fuel);
951        }
952
953        let execution_result = execute_builder.run().await;
954
955        // Wait for the handler tasks to complete
956        let callback_invocations = callback_handler.await.unwrap_or(0);
957        let trace_events = match trace_collector {
958            Some(trace_collector) => trace_collector.await.unwrap_or_default(),
959            None => Vec::new(),
960        };
961
962        // Output handler completes when its channel is dropped
963        if let Some(task) = output_handler_task {
964            let _ = task.await;
965        }
966
967        // Network handler completes when its channel is dropped
968        if let Some(handler) = net_handler {
969            let _ = handler.await;
970        }
971
972        let duration = start.elapsed();
973
974        match execution_result {
975            Ok(output) => {
976                // Scrub secret placeholders from final output based on policy
977                let stdout = if matches!(scrub_stdout, crate::secrets::OutputScrubPolicy::All) {
978                    crate::secrets::scrub_placeholders(&output.stdout, &secrets)
979                } else {
980                    output.stdout
981                };
982
983                let stderr = if matches!(scrub_stderr, crate::secrets::OutputScrubPolicy::All) {
984                    crate::secrets::scrub_placeholders(&output.stderr, &secrets)
985                } else {
986                    output.stderr
987                };
988
989                // The structured result is scrubbed only when explicitly opted in
990                // (it's a programmatic side channel). Scrub the error message too.
991                let (result, result_error) = if scrub_result {
992                    (
993                        output
994                            .result
995                            .map(|r| crate::secrets::scrub_placeholders(&r, &secrets)),
996                        output
997                            .result_error
998                            .map(|e| crate::secrets::scrub_placeholders(&e, &secrets)),
999                    )
1000                } else {
1001                    (output.result, output.result_error)
1002                };
1003
1004                Ok(ExecuteResult {
1005                    stdout,
1006                    stderr,
1007                    trace: trace_events,
1008                    result,
1009                    result_error,
1010                    stats: ExecuteStats {
1011                        duration,
1012                        callback_invocations,
1013                        peak_memory_bytes: Some(output.peak_memory_bytes),
1014                        fuel_consumed: output.fuel_consumed,
1015                    },
1016                })
1017            }
1018            Err(error) => {
1019                // Check if this was a cancellation (either from the error or from the token)
1020                if matches!(error, Error::Cancelled) || cancel_token.is_cancelled() {
1021                    Err(Error::Cancelled)
1022                } else {
1023                    Err(error)
1024                }
1025            }
1026        }
1027    }
1028}
1029
1030/// Handle to a cancellable execution.
1031///
1032/// Created by [`Sandbox::execute_cancellable`]. Use this handle to cancel
1033/// the execution or wait for its completion.
1034///
1035/// The handle can be cloned to share cancellation control across tasks.
1036#[derive(Debug)]
1037pub struct ExecutionHandle {
1038    /// Token used to signal cancellation.
1039    cancel_token: CancellationToken,
1040    /// Receiver for the execution result.
1041    result: oneshot::Receiver<Result<ExecuteResult, Error>>,
1042}
1043
1044impl ExecutionHandle {
1045    /// Cancel the execution.
1046    ///
1047    /// This signals the WASM runtime to interrupt execution. The cancellation
1048    /// is asynchronous - the execution may not stop immediately, especially
1049    /// if it's currently in a host callback.
1050    ///
1051    /// Calling cancel multiple times has no additional effect.
1052    pub fn cancel(&self) {
1053        self.cancel_token.cancel();
1054    }
1055
1056    /// Check if the execution is still running.
1057    ///
1058    /// Returns `false` if the execution has completed (successfully or with error)
1059    /// or if it has been cancelled.
1060    #[must_use]
1061    pub fn is_running(&self) -> bool {
1062        !self.cancel_token.is_cancelled()
1063    }
1064
1065    /// Get a clone of the cancellation token.
1066    ///
1067    /// This is useful for integrating with other cancellation-aware code.
1068    #[must_use]
1069    pub fn cancellation_token(&self) -> CancellationToken {
1070        self.cancel_token.clone()
1071    }
1072
1073    /// Wait for the execution to complete.
1074    ///
1075    /// Returns the execution result or an error. If the execution was cancelled,
1076    /// returns [`Error::Cancelled`].
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns an error if:
1081    /// - The execution was cancelled ([`Error::Cancelled`])
1082    /// - The Python code raised an exception
1083    /// - A resource limit was exceeded
1084    /// - The execution timed out
1085    pub async fn wait(self) -> Result<ExecuteResult, Error> {
1086        match self.result.await {
1087            Ok(result) => result,
1088            Err(_) => {
1089                // Channel closed without sending - execution was likely cancelled
1090                // or the task panicked
1091                if self.cancel_token.is_cancelled() {
1092                    Err(Error::Cancelled)
1093                } else {
1094                    Err(Error::Execution("execution task failed".to_string()))
1095                }
1096            }
1097        }
1098    }
1099}
1100
1101/// Shared pre-compiled component bytes with an optional content-derived cache key.
1102///
1103/// This type keeps the bytes and cache key together so callers cannot associate
1104/// a cache entry with different component bytes.
1105#[cfg(any(feature = "embedded", feature = "preinit"))]
1106#[derive(Clone)]
1107pub struct PrecompiledArtifact {
1108    bytes: Arc<Vec<u8>>,
1109    cache_key: Option<crate::cache::CacheKey>,
1110}
1111
1112#[cfg(any(feature = "embedded", feature = "preinit"))]
1113impl PrecompiledArtifact {
1114    /// Create an uncached pre-compiled artifact.
1115    #[must_use]
1116    pub fn new(bytes: Vec<u8>) -> Self {
1117        Self {
1118            bytes: Arc::new(bytes),
1119            cache_key: None,
1120        }
1121    }
1122
1123    /// Create a cached pre-compiled artifact using a content-derived key.
1124    ///
1125    /// The component bytes are hashed once during construction. Equivalent
1126    /// artifacts then share the process-global [`crate::cache::InstancePreCache`].
1127    #[cfg(feature = "embedded")]
1128    #[must_use]
1129    pub fn new_cached(bytes: Vec<u8>) -> Self {
1130        let cache_key = crate::cache::CacheKey::from_precompiled(&bytes);
1131        Self {
1132            bytes: Arc::new(bytes),
1133            cache_key: Some(cache_key),
1134        }
1135    }
1136
1137    /// Access the pre-compiled component bytes.
1138    #[must_use]
1139    pub fn as_bytes(&self) -> &[u8] {
1140        self.bytes.as_slice()
1141    }
1142
1143    /// Return the size of the pre-compiled component in bytes.
1144    #[must_use]
1145    pub fn len(&self) -> usize {
1146        self.bytes.len()
1147    }
1148
1149    /// Return whether the pre-compiled component is empty.
1150    #[must_use]
1151    pub fn is_empty(&self) -> bool {
1152        self.bytes.is_empty()
1153    }
1154
1155    fn cache_key(&self) -> Option<&crate::cache::CacheKey> {
1156        self.cache_key.as_ref()
1157    }
1158}
1159
1160#[cfg(any(feature = "embedded", feature = "preinit"))]
1161impl std::fmt::Debug for PrecompiledArtifact {
1162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1163        f.debug_struct("PrecompiledArtifact")
1164            .field("size_bytes", &self.bytes.len())
1165            .field("cached", &self.cache_key.is_some())
1166            .finish()
1167    }
1168}
1169
1170/// Source of the WASM component for the sandbox.
1171#[derive(Debug, Clone, Default)]
1172enum WasmSource {
1173    /// No source specified yet.
1174    #[default]
1175    None,
1176    /// WASM component bytes (will be compiled at load time).
1177    Bytes(Vec<u8>),
1178    /// Path to a WASM component file (will be compiled at load time).
1179    File(std::path::PathBuf),
1180    /// Pre-compiled component bytes (skip compilation, unsafe).
1181    #[cfg(any(feature = "embedded", feature = "preinit"))]
1182    PrecompiledBytes(Vec<u8>),
1183    /// Shared pre-compiled component artifact (skip compilation, unsafe).
1184    ///
1185    /// Unlike [`WasmSource::PrecompiledBytes`], the bytes are not copied when
1186    /// the sandbox is created, making repeated creation from a long-lived
1187    /// artifact cheap.
1188    #[cfg(any(feature = "embedded", feature = "preinit"))]
1189    PrecompiledArtifact(PrecompiledArtifact),
1190    /// Path to a pre-compiled component file (skip compilation, unsafe).
1191    #[cfg(any(feature = "embedded", feature = "preinit"))]
1192    PrecompiledFile(std::path::PathBuf),
1193    /// Use the embedded pre-compiled runtime (safe, fast).
1194    #[cfg(feature = "embedded")]
1195    EmbeddedRuntime,
1196}
1197
1198/// Builder for constructing a [`Sandbox`].
1199///
1200/// Type parameters track configuration state at compile time:
1201/// - `Runtime`: Whether WASM runtime is configured ([`state::Needs`] or [`state::Has`])
1202/// - `Stdlib`: Whether Python stdlib is configured ([`state::Needs`] or [`state::Has`])
1203///
1204/// The [`build()`](SandboxBuilder::build) method is only available when both are [`state::Has`].
1205///
1206/// # Examples
1207///
1208/// ```rust,ignore
1209/// // With embedded feature - simplest path
1210/// let sandbox = Sandbox::embedded()
1211///     .with_callback(MyCallback)
1212///     .build()?;
1213///
1214/// // Without embedded - must specify both runtime and stdlib
1215/// let sandbox = Sandbox::builder()
1216///     .with_wasm_file("runtime.wasm")
1217///     .with_python_stdlib("/path/to/stdlib")
1218///     .build()?;
1219/// ```
1220pub struct SandboxBuilder<Runtime = state::Needs, Stdlib = state::Needs> {
1221    wasm_source: WasmSource,
1222    callbacks: HashMap<String, Arc<dyn Callback>>,
1223    preamble: String,
1224    type_stubs: String,
1225    trace_handler: Option<Arc<dyn TraceHandler>>,
1226    collect_trace: bool,
1227    output_handler: Option<Arc<dyn OutputHandler>>,
1228    resource_limits: ResourceLimits,
1229    /// Name of the user variable captured as the structured result. Default `result`.
1230    result_variable: String,
1231    /// Path to Python stdlib for eryx-wasm-runtime.
1232    python_stdlib_path: Option<std::path::PathBuf>,
1233    /// Path to Python site-packages for eryx-wasm-runtime.
1234    python_site_packages_path: Option<std::path::PathBuf>,
1235    /// Native Python extensions to link into the component.
1236    #[cfg(feature = "native-extensions")]
1237    native_extensions: Vec<eryx_runtime::linker::NativeExtension>,
1238    /// Component cache for faster sandbox creation with native extensions.
1239    #[cfg(feature = "native-extensions")]
1240    cache: Option<Arc<dyn ComponentCache>>,
1241    /// Filesystem cache directory for mmap-based loading (faster than bytes).
1242    #[cfg(feature = "native-extensions")]
1243    filesystem_cache: Option<crate::cache::FilesystemCache>,
1244    /// Extracted packages (kept alive for sandbox lifetime).
1245    packages: Vec<crate::package::ExtractedPackage>,
1246    /// Network configuration for TLS connections.
1247    net_config: Option<crate::net::NetConfig>,
1248    /// Secrets configuration (name -> SecretConfig).
1249    secrets: HashMap<String, crate::secrets::SecretConfig>,
1250    /// Stdout scrubbing policy.
1251    scrub_stdout: crate::secrets::OutputScrubPolicy,
1252    /// Stderr scrubbing policy.
1253    scrub_stderr: crate::secrets::OutputScrubPolicy,
1254    /// Whether to scrub secret placeholders from the structured `result` (and
1255    /// `result_error`) channel. Defaults to `false`: unlike stdout/stderr, the
1256    /// result is a programmatic side channel, so scrubbing is opt-in.
1257    scrub_result: bool,
1258    /// File scrubbing policy for VFS integration.
1259    scrub_files: crate::secrets::FileScrubPolicy,
1260    /// Host filesystem volume mounts.
1261    #[cfg(feature = "vfs")]
1262    volumes: Vec<crate::session::VolumeMount>,
1263    /// Previous callback journal to replay from (see
1264    /// [`with_replay_journal`](SandboxBuilder::with_replay_journal)).
1265    replay_journal: Option<CallbackJournal>,
1266    /// Phantom data for Runtime type parameter.
1267    _runtime: PhantomData<Runtime>,
1268    /// Phantom data for Stdlib type parameter.
1269    _stdlib: PhantomData<Stdlib>,
1270}
1271
1272impl Default for SandboxBuilder<state::Needs, state::Needs> {
1273    fn default() -> Self {
1274        Self::new()
1275    }
1276}
1277
1278impl<R, S> std::fmt::Debug for SandboxBuilder<R, S> {
1279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1280        f.debug_struct("SandboxBuilder")
1281            .field(
1282                "callbacks",
1283                &format!("[{} callbacks]", self.callbacks.len()),
1284            )
1285            .field("preamble_len", &self.preamble.len())
1286            .field("type_stubs_len", &self.type_stubs.len())
1287            .field("has_trace_handler", &self.trace_handler.is_some())
1288            .field("collect_trace", &self.collect_trace)
1289            .field("has_output_handler", &self.output_handler.is_some())
1290            .field("resource_limits", &self.resource_limits)
1291            .field("wasm_source", &self.wasm_source)
1292            .finish()
1293    }
1294}
1295
1296impl SandboxBuilder<state::Needs, state::Needs> {
1297    /// Create a new sandbox builder with default settings.
1298    ///
1299    /// You must configure both a runtime source and Python stdlib before building.
1300    /// Use [`Sandbox::embedded()`] for zero-config setup when the `embedded` feature is enabled.
1301    #[must_use]
1302    pub fn new() -> Self {
1303        Self {
1304            wasm_source: WasmSource::None,
1305            callbacks: HashMap::new(),
1306            preamble: String::new(),
1307            type_stubs: String::new(),
1308            trace_handler: None,
1309            collect_trace: true,
1310            output_handler: None,
1311            resource_limits: ResourceLimits::default(),
1312            result_variable: "result".to_string(),
1313            python_stdlib_path: None,
1314            python_site_packages_path: None,
1315            #[cfg(feature = "native-extensions")]
1316            native_extensions: Vec::new(),
1317            #[cfg(feature = "native-extensions")]
1318            cache: None,
1319            #[cfg(feature = "native-extensions")]
1320            filesystem_cache: None,
1321            packages: Vec::new(),
1322            net_config: None,
1323            secrets: HashMap::new(),
1324            scrub_stdout: crate::secrets::OutputScrubPolicy::default(),
1325            scrub_stderr: crate::secrets::OutputScrubPolicy::default(),
1326            scrub_result: false,
1327            scrub_files: crate::secrets::FileScrubPolicy::default(),
1328            #[cfg(feature = "vfs")]
1329            volumes: Vec::new(),
1330            replay_journal: None,
1331            _runtime: PhantomData,
1332            _stdlib: PhantomData,
1333        }
1334    }
1335}
1336
1337/// Create a builder pre-configured with embedded runtime and stdlib.
1338#[cfg(feature = "embedded")]
1339impl SandboxBuilder<state::Needs, state::Needs> {
1340    fn new_embedded() -> SandboxBuilder<state::Has, state::Has> {
1341        SandboxBuilder {
1342            wasm_source: WasmSource::EmbeddedRuntime,
1343            callbacks: HashMap::new(),
1344            preamble: String::new(),
1345            type_stubs: String::new(),
1346            trace_handler: None,
1347            collect_trace: true,
1348            output_handler: None,
1349            resource_limits: ResourceLimits::default(),
1350            result_variable: "result".to_string(),
1351            python_stdlib_path: None, // Will use embedded stdlib
1352            python_site_packages_path: None,
1353            #[cfg(feature = "native-extensions")]
1354            native_extensions: Vec::new(),
1355            #[cfg(feature = "native-extensions")]
1356            cache: None,
1357            #[cfg(feature = "native-extensions")]
1358            filesystem_cache: None,
1359            packages: Vec::new(),
1360            net_config: None,
1361            secrets: HashMap::new(),
1362            scrub_stdout: crate::secrets::OutputScrubPolicy::default(),
1363            scrub_stderr: crate::secrets::OutputScrubPolicy::default(),
1364            scrub_result: false,
1365            scrub_files: crate::secrets::FileScrubPolicy::default(),
1366            #[cfg(feature = "vfs")]
1367            volumes: Vec::new(),
1368            replay_journal: None,
1369            _runtime: PhantomData,
1370            _stdlib: PhantomData,
1371        }
1372    }
1373}
1374
1375// Helper for state transitions
1376impl<R, S> SandboxBuilder<R, S> {
1377    /// Internal: transition to new state while preserving all fields.
1378    fn transition<R2, S2>(self) -> SandboxBuilder<R2, S2> {
1379        SandboxBuilder {
1380            wasm_source: self.wasm_source,
1381            callbacks: self.callbacks,
1382            preamble: self.preamble,
1383            type_stubs: self.type_stubs,
1384            trace_handler: self.trace_handler,
1385            collect_trace: self.collect_trace,
1386            output_handler: self.output_handler,
1387            resource_limits: self.resource_limits,
1388            result_variable: self.result_variable,
1389            python_stdlib_path: self.python_stdlib_path,
1390            python_site_packages_path: self.python_site_packages_path,
1391            #[cfg(feature = "native-extensions")]
1392            native_extensions: self.native_extensions,
1393            #[cfg(feature = "native-extensions")]
1394            cache: self.cache,
1395            #[cfg(feature = "native-extensions")]
1396            filesystem_cache: self.filesystem_cache,
1397            packages: self.packages,
1398            net_config: self.net_config,
1399            secrets: self.secrets,
1400            scrub_stdout: self.scrub_stdout,
1401            scrub_stderr: self.scrub_stderr,
1402            scrub_result: self.scrub_result,
1403            scrub_files: self.scrub_files,
1404            #[cfg(feature = "vfs")]
1405            volumes: self.volumes,
1406            replay_journal: self.replay_journal,
1407            _runtime: PhantomData,
1408            _stdlib: PhantomData,
1409        }
1410    }
1411}
1412
1413// Runtime transitions (Needs -> Has)
1414impl<S> SandboxBuilder<state::Needs, S> {
1415    /// Explicitly use the embedded pre-compiled runtime.
1416    ///
1417    /// **Note:** You usually don't need to call this. When the `embedded`
1418    /// feature is enabled, the embedded runtime is used automatically for
1419    /// sandboxes without native extensions. This method exists for explicit
1420    /// control in advanced use cases.
1421    ///
1422    /// # Automatic Runtime Selection
1423    ///
1424    /// The runtime is selected automatically based on your configuration:
1425    ///
1426    /// - **No native extensions** → Embedded runtime (fast, ~2ms)
1427    /// - **Has native extensions** → Late-linking (required for .so files)
1428    ///
1429    /// ```rust,ignore
1430    /// // These are equivalent when embedded feature is enabled:
1431    /// let sandbox = Sandbox::builder().build()?;
1432    /// let sandbox = Sandbox::builder().with_embedded_runtime().build()?;
1433    ///
1434    /// // With native extensions, late-linking happens automatically:
1435    /// let sandbox = Sandbox::builder()
1436    ///     .with_package("/path/to/numpy-wasi.tar.gz")?  // Has .so files
1437    ///     .build()?;  // Uses late-linking, not embedded runtime
1438    /// ```
1439    /// Explicitly use the embedded pre-compiled runtime and stdlib.
1440    ///
1441    /// This transitions the builder to a fully-configured state, ready to build.
1442    ///
1443    /// **Note:** Consider using [`Sandbox::embedded()`] instead for cleaner code.
1444    #[cfg(feature = "embedded")]
1445    #[must_use]
1446    pub fn with_embedded_runtime(mut self) -> SandboxBuilder<state::Has, state::Has> {
1447        self.wasm_source = WasmSource::EmbeddedRuntime;
1448        self.transition()
1449    }
1450
1451    /// Set the WASM component from bytes.
1452    ///
1453    /// Use this to embed the WASM component in your binary.
1454    /// You still need to configure the Python stdlib with [`with_python_stdlib()`](SandboxBuilder::with_python_stdlib)
1455    /// or [`with_auto_stdlib()`](SandboxBuilder::with_auto_stdlib).
1456    #[must_use]
1457    pub fn with_wasm_bytes(mut self, bytes: impl Into<Vec<u8>>) -> SandboxBuilder<state::Has, S> {
1458        self.wasm_source = WasmSource::Bytes(bytes.into());
1459        self.transition()
1460    }
1461
1462    /// Set the WASM component from a file path.
1463    ///
1464    /// You still need to configure the Python stdlib with [`with_python_stdlib()`](SandboxBuilder::with_python_stdlib)
1465    /// or [`with_auto_stdlib()`](SandboxBuilder::with_auto_stdlib).
1466    #[must_use]
1467    pub fn with_wasm_file(
1468        mut self,
1469        path: impl Into<std::path::PathBuf>,
1470    ) -> SandboxBuilder<state::Has, S> {
1471        self.wasm_source = WasmSource::File(path.into());
1472        self.transition()
1473    }
1474
1475    /// Set the WASM component from pre-compiled bytes.
1476    ///
1477    /// Pre-compiled components load much faster because they skip compilation
1478    /// (~50x faster sandbox creation). Create pre-compiled bytes using
1479    /// `PythonExecutor::precompile()`.
1480    ///
1481    /// # Safety
1482    ///
1483    /// This function is unsafe because wasmtime cannot fully validate
1484    /// pre-compiled components for safety. Loading untrusted pre-compiled
1485    /// bytes can lead to **arbitrary code execution**.
1486    ///
1487    /// Only call this with pre-compiled bytes that:
1488    /// - Were created by `PythonExecutor::precompile()` or `precompile_file()`
1489    /// - Come from a trusted source you control
1490    /// - Were compiled with a compatible wasmtime version and configuration
1491    ///
1492    /// # Example
1493    ///
1494    /// ```rust,ignore
1495    /// // Pre-compile once (safe operation)
1496    /// let precompiled = PythonExecutor::precompile_file("runtime.wasm")?;
1497    ///
1498    /// // Load from pre-compiled (unsafe - you must trust the bytes)
1499    /// let sandbox = unsafe {
1500    ///     Sandbox::builder()
1501    ///         .with_precompiled_bytes(precompiled)
1502    ///         .with_python_stdlib("/path/to/stdlib")
1503    ///         .build()?
1504    /// };
1505    /// ```
1506    #[cfg(any(feature = "embedded", feature = "preinit"))]
1507    #[must_use]
1508    #[allow(unsafe_code)]
1509    pub unsafe fn with_precompiled_bytes(
1510        mut self,
1511        bytes: impl Into<Vec<u8>>,
1512    ) -> SandboxBuilder<state::Has, S> {
1513        self.wasm_source = WasmSource::PrecompiledBytes(bytes.into());
1514        self.transition()
1515    }
1516
1517    /// Set the WASM component from a shared [`PrecompiledArtifact`].
1518    ///
1519    /// Pre-compiled components load much faster because they skip compilation
1520    /// (~50x faster sandbox creation). Create pre-compiled files using
1521    /// `PythonExecutor::precompile_file()`.
1522    ///
1523    /// Unlike [`Self::with_precompiled_bytes`], the artifact is cheaply cloned,
1524    /// so repeated sandbox creation does not copy its component bytes. When the
1525    /// `embedded` feature is enabled, use `PrecompiledArtifact::new_cached` to
1526    /// enable content-safe caching.
1527    ///
1528    /// # Safety
1529    ///
1530    /// This function is unsafe because wasmtime cannot fully validate
1531    /// pre-compiled components for safety. Loading untrusted pre-compiled
1532    /// bytes can lead to **arbitrary code execution**.
1533    ///
1534    /// Only call this with pre-compiled bytes that:
1535    /// - Were created by `PythonExecutor::precompile()` or `precompile_file()`
1536    /// - Come from a trusted source you control
1537    /// - Were compiled with a compatible wasmtime version and configuration
1538    #[cfg(any(feature = "embedded", feature = "preinit"))]
1539    #[must_use]
1540    #[allow(unsafe_code)]
1541    pub unsafe fn with_precompiled_artifact(
1542        mut self,
1543        artifact: PrecompiledArtifact,
1544    ) -> SandboxBuilder<state::Has, S> {
1545        self.wasm_source = WasmSource::PrecompiledArtifact(artifact);
1546        self.transition()
1547    }
1548
1549    /// Set the WASM component from a pre-compiled file path.
1550    ///
1551    /// Pre-compiled components load much faster because they skip compilation
1552    /// (~50x faster sandbox creation). Create pre-compiled files using
1553    /// `PythonExecutor::precompile_file()`.
1554    ///
1555    /// # Safety
1556    ///
1557    /// This function is unsafe because wasmtime cannot fully validate
1558    /// pre-compiled components for safety. Loading untrusted pre-compiled
1559    /// files can lead to **arbitrary code execution**.
1560    ///
1561    /// Only call this with pre-compiled files that:
1562    /// - Were created by `PythonExecutor::precompile()` or `precompile_file()`
1563    /// - Come from a trusted source you control
1564    /// - Were compiled with a compatible wasmtime version and configuration
1565    ///
1566    /// # Example
1567    ///
1568    /// ```rust,ignore
1569    /// // Pre-compile once and save to disk
1570    /// let precompiled = PythonExecutor::precompile_file("runtime.wasm")?;
1571    /// std::fs::write("runtime.cwasm", &precompiled)?;
1572    ///
1573    /// // Load from pre-compiled file (unsafe - you must trust the file)
1574    /// let sandbox = unsafe {
1575    ///     Sandbox::builder()
1576    ///         .with_precompiled_file("runtime.cwasm")
1577    ///         .with_python_stdlib("/path/to/stdlib")
1578    ///         .build()?
1579    /// };
1580    /// ```
1581    #[cfg(any(feature = "embedded", feature = "preinit"))]
1582    #[must_use]
1583    #[allow(unsafe_code)]
1584    pub unsafe fn with_precompiled_file(
1585        mut self,
1586        path: impl Into<std::path::PathBuf>,
1587    ) -> SandboxBuilder<state::Has, S> {
1588        self.wasm_source = WasmSource::PrecompiledFile(path.into());
1589        self.transition()
1590    }
1591}
1592
1593// Stdlib transitions (Needs -> Has)
1594impl<R> SandboxBuilder<R, state::Needs> {
1595    /// Set the path to the Python standard library directory.
1596    ///
1597    /// This is required when not using the `embedded` feature.
1598    /// The directory should contain the extracted Python stdlib (e.g., from
1599    /// componentize-py's python-lib.tar.zst).
1600    ///
1601    /// The stdlib will be mounted at `/python-stdlib` inside the WASM sandbox.
1602    #[must_use]
1603    pub fn with_python_stdlib(
1604        mut self,
1605        path: impl Into<std::path::PathBuf>,
1606    ) -> SandboxBuilder<R, state::Has> {
1607        self.python_stdlib_path = Some(path.into());
1608        self.transition()
1609    }
1610
1611    /// Auto-detect Python stdlib from common locations.
1612    ///
1613    /// Searches in order:
1614    /// 1. `ERYX_PYTHON_STDLIB` environment variable
1615    /// 2. `./python-stdlib` (relative to current directory)
1616    /// 3. `<exe_dir>/python-stdlib` (relative to executable)
1617    /// 4. `<exe_dir>/../python-stdlib` (sibling of executable directory)
1618    ///
1619    /// # Errors
1620    ///
1621    /// Returns [`Error::MissingPythonStdlib`] if no valid stdlib directory is found.
1622    ///
1623    /// # Example
1624    ///
1625    /// ```rust,ignore
1626    /// let sandbox = Sandbox::builder()
1627    ///     .with_wasm_file("runtime.wasm")
1628    ///     .with_auto_stdlib()?  // Explicit fallible auto-detection
1629    ///     .build()?;
1630    /// ```
1631    pub fn with_auto_stdlib(self) -> Result<SandboxBuilder<R, state::Has>, Error> {
1632        let path = find_python_stdlib().ok_or(Error::MissingPythonStdlib)?;
1633        Ok(self.with_python_stdlib(path))
1634    }
1635
1636    /// Use the embedded Python standard library.
1637    ///
1638    /// Extracts the stdlib bundled in the binary to a cached temp directory
1639    /// and configures the builder to use it. This is useful when loading a
1640    /// custom pre-compiled runtime via [`with_precompiled_file()`](SandboxBuilder::with_precompiled_file)
1641    /// but still wanting the convenience of the embedded stdlib.
1642    ///
1643    /// Requires the `embedded-stdlib` feature (also enabled by `embedded`).
1644    ///
1645    /// # Errors
1646    ///
1647    /// Returns an error if stdlib extraction fails.
1648    ///
1649    /// # Example
1650    ///
1651    /// ```rust,ignore
1652    /// let sandbox = unsafe {
1653    ///     Sandbox::builder()
1654    ///         .with_precompiled_file("custom-runtime.cwasm")
1655    ///         .with_embedded_stdlib()?
1656    ///         .build()?
1657    /// };
1658    /// ```
1659    #[cfg(feature = "embedded-stdlib")]
1660    pub fn with_embedded_stdlib(self) -> Result<SandboxBuilder<R, state::Has>, Error> {
1661        let stdlib = crate::embedded_stdlib::EmbeddedStdlib::get()?;
1662        Ok(self.with_python_stdlib(stdlib.path()))
1663    }
1664}
1665
1666// Methods available in ANY state
1667impl<R, S> SandboxBuilder<R, S> {
1668    /// Add a native Python extension (.so file) to be linked into the component.
1669    ///
1670    /// Native extensions allow Python packages with compiled code (like numpy)
1671    /// to work in the sandbox. The extension is linked into the WASM component
1672    /// at sandbox creation time using late-linking.
1673    ///
1674    /// # Arguments
1675    ///
1676    /// * `name` - The name of the .so file (e.g., "numpy/core/_multiarray_umath.cpython-314-wasm32-wasi.so")
1677    /// * `bytes` - The raw WASM bytes of the compiled extension
1678    ///
1679    /// # Example
1680    ///
1681    /// ```rust,ignore
1682    /// // Load numpy native extension
1683    /// let numpy_core = std::fs::read("numpy/core/_multiarray_umath.cpython-314-wasm32-wasi.so")?;
1684    ///
1685    /// let sandbox = Sandbox::builder()
1686    ///     .with_native_extension("numpy/core/_multiarray_umath.cpython-314-wasm32-wasi.so", numpy_core)
1687    ///     .with_site_packages("path/to/site-packages")  // For Python files
1688    ///     .build()?;
1689    ///
1690    /// // Now numpy can be imported!
1691    /// let result = sandbox.execute("import numpy as np; print(np.array([1,2,3]).sum())").await?;
1692    /// ```
1693    ///
1694    /// # Note
1695    ///
1696    /// When native extensions are added, the sandbox creation is slower because
1697    /// the component needs to be re-linked. Consider caching the linked component
1698    /// for repeated use with the same extensions.
1699    #[cfg(feature = "native-extensions")]
1700    #[must_use]
1701    pub fn with_native_extension(
1702        mut self,
1703        name: impl Into<String>,
1704        bytes: impl Into<Vec<u8>>,
1705    ) -> Self {
1706        self.native_extensions
1707            .push(eryx_runtime::linker::NativeExtension::new(
1708                name,
1709                bytes.into(),
1710            ));
1711        self
1712    }
1713
1714    /// Set a component cache for faster sandbox creation with native extensions.
1715    ///
1716    /// When native extensions are used, the sandbox must link them into the base
1717    /// component and then JIT compile the result. This can take 500-1000ms.
1718    ///
1719    /// With caching enabled, the linked and pre-compiled component is stored and
1720    /// reused on subsequent calls, reducing creation time to ~10ms.
1721    ///
1722    /// # Example
1723    ///
1724    /// ```rust,ignore
1725    /// use eryx::{Sandbox, cache::InMemoryCache};
1726    ///
1727    /// let cache = InMemoryCache::new();
1728    ///
1729    /// // First call: ~1000ms (link + compile + cache)
1730    /// let sandbox1 = Sandbox::builder()
1731    ///     .with_native_extension("numpy/core/*.so", bytes)
1732    ///     .with_cache(Arc::new(cache.clone()))
1733    ///     .build()?;
1734    ///
1735    /// // Second call: ~10ms (cache hit)
1736    /// let sandbox2 = Sandbox::builder()
1737    ///     .with_native_extension("numpy/core/*.so", bytes)
1738    ///     .with_cache(Arc::new(cache))
1739    ///     .build()?;
1740    /// ```
1741    #[cfg(feature = "native-extensions")]
1742    #[must_use]
1743    pub fn with_cache(mut self, cache: Arc<dyn ComponentCache>) -> Self {
1744        self.cache = Some(cache);
1745        self
1746    }
1747
1748    /// Set a custom filesystem cache directory for late-linked components.
1749    ///
1750    /// **Note:** You usually don't need to call this. A default cache at
1751    /// `$TMPDIR/eryx-cache` is used automatically when native extensions are present.
1752    /// Use this method only if you need a specific cache location.
1753    ///
1754    /// The cache stores pre-compiled WASM components to avoid expensive
1755    /// re-linking on subsequent sandbox creations with the same extensions.
1756    ///
1757    /// # Errors
1758    ///
1759    /// Returns an error if the cache directory cannot be created.
1760    ///
1761    /// # Example
1762    ///
1763    /// ```rust,ignore
1764    /// // Usually not needed - default cache is automatic
1765    /// let sandbox = Sandbox::builder()
1766    ///     .with_package("/path/to/numpy.tar.gz")?
1767    ///     .build()?;  // Uses $TMPDIR/eryx-cache automatically
1768    ///
1769    /// // Only if you need a specific location:
1770    /// let sandbox = Sandbox::builder()
1771    ///     .with_package("/path/to/numpy.tar.gz")?
1772    ///     .with_cache_dir("/custom/cache/path")?
1773    ///     .build()?;
1774    /// ```
1775    ///
1776    /// [`FilesystemCache`]: crate::cache::FilesystemCache
1777    #[cfg(feature = "native-extensions")]
1778    pub fn with_cache_dir(mut self, path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
1779        let cache = crate::cache::FilesystemCache::new(path)
1780            .map_err(|e| Error::Initialization(format!("failed to create cache directory: {e}")))?;
1781        // Store filesystem cache for mmap-based loading (3x faster than bytes)
1782        self.filesystem_cache = Some(cache.clone());
1783        Ok(self.with_cache(Arc::new(cache)))
1784    }
1785
1786    /// Add a runtime library (callbacks + preamble + stubs).
1787    #[must_use]
1788    pub fn with_library(mut self, library: RuntimeLibrary) -> Self {
1789        // Add callbacks from the library
1790        for callback in library.callbacks {
1791            self.callbacks
1792                .insert(callback.name().to_string(), Arc::from(callback));
1793        }
1794
1795        // Append preamble
1796        if !library.python_preamble.is_empty() {
1797            if !self.preamble.is_empty() {
1798                self.preamble.push('\n');
1799            }
1800            self.preamble.push_str(&library.python_preamble);
1801        }
1802
1803        // Append type stubs
1804        if !library.type_stubs.is_empty() {
1805            if !self.type_stubs.is_empty() {
1806                self.type_stubs.push('\n');
1807            }
1808            self.type_stubs.push_str(&library.type_stubs);
1809        }
1810
1811        self
1812    }
1813
1814    /// Add individual callbacks.
1815    #[must_use]
1816    pub fn with_callbacks(mut self, callbacks: Vec<Box<dyn Callback>>) -> Self {
1817        for callback in callbacks {
1818            self.callbacks
1819                .insert(callback.name().to_string(), Arc::from(callback));
1820        }
1821        self
1822    }
1823
1824    /// Add a single callback.
1825    #[must_use]
1826    pub fn with_callback(mut self, callback: impl Callback + 'static) -> Self {
1827        let boxed: Box<dyn Callback> = Box::new(callback);
1828        self.callbacks
1829            .insert(boxed.name().to_string(), Arc::from(boxed));
1830        self
1831    }
1832
1833    /// Replay callback results from a previously-recorded journal.
1834    ///
1835    /// When set, [`Sandbox::execute_with_journal`] wraps every registered
1836    /// callback so that invocations matching `journal` (by callback name plus
1837    /// canonical arguments, consuming cached results FIFO per key) return the
1838    /// cached result instead of running live. The first miss (a callback not in
1839    /// the journal) switches to live execution for the remainder of the run. See
1840    /// the [`replay`](crate::replay) module for the full model.
1841    ///
1842    /// This only affects [`Sandbox::execute_with_journal`]; plain
1843    /// [`Sandbox::execute`] ignores it.
1844    ///
1845    /// # Security
1846    ///
1847    /// Journal entries are replayed verbatim — a crafted journal can inject
1848    /// arbitrary callback results. Only use journals from a trusted source
1849    /// (a previous execution you control, or one verified via HMAC signature).
1850    #[must_use]
1851    pub fn with_replay_journal(mut self, journal: CallbackJournal) -> Self {
1852        self.replay_journal = Some(journal);
1853        self
1854    }
1855
1856    /// Set a trace handler for execution progress.
1857    #[must_use]
1858    pub fn with_trace_handler<H: TraceHandler + 'static>(mut self, handler: H) -> Self {
1859        self.trace_handler = Some(Arc::new(handler));
1860        self
1861    }
1862
1863    /// Configure whether execution trace events are collected in the result.
1864    ///
1865    /// Trace collection is enabled by default for backward compatibility. It
1866    /// installs Python's `sys.settrace` hook, which can be expensive for
1867    /// instruction-heavy workloads. A configured [`TraceHandler`] always keeps
1868    /// tracing enabled regardless of this setting.
1869    #[must_use]
1870    pub const fn with_trace_collection(mut self, enabled: bool) -> Self {
1871        self.collect_trace = enabled;
1872        self
1873    }
1874
1875    /// Set an output handler for streaming stdout.
1876    #[must_use]
1877    pub fn with_output_handler<H: OutputHandler + 'static>(mut self, handler: H) -> Self {
1878        self.output_handler = Some(Arc::new(handler));
1879        self
1880    }
1881
1882    /// Set resource limits.
1883    #[must_use]
1884    pub const fn with_resource_limits(mut self, limits: ResourceLimits) -> Self {
1885        self.resource_limits = limits;
1886        self
1887    }
1888
1889    /// Set the name of the user variable captured as the structured result.
1890    ///
1891    /// After each `execute()`, the variable with this name is read from the script's
1892    /// namespace, JSON-serialized, and returned as [`ExecuteResult::result`]. If the
1893    /// value is not JSON-serializable, [`ExecuteResult::result_error`] explains why and
1894    /// `result` is `None` — execution still succeeds. Defaults to `"result"`.
1895    #[must_use]
1896    pub fn with_result_variable(mut self, name: impl Into<String>) -> Self {
1897        self.result_variable = name.into();
1898        self
1899    }
1900
1901    /// Enable TLS networking with the given configuration.
1902    ///
1903    /// This allows Python code in the sandbox to make HTTPS requests using
1904    /// libraries like `requests` or `httpx`. The configuration controls which
1905    /// hosts are allowed, connection limits, and timeouts.
1906    ///
1907    /// # Example
1908    ///
1909    /// ```rust,ignore
1910    /// use eryx::{Sandbox, NetConfig};
1911    ///
1912    /// let sandbox = Sandbox::embedded()
1913    ///     .with_network(NetConfig::default())
1914    ///     .build()?;
1915    ///
1916    /// // Python code can now use requests/httpx
1917    /// sandbox.execute(r#"
1918    /// import requests
1919    /// r = requests.get("https://httpbin.org/get")
1920    /// print(r.status_code)
1921    /// "#).await?;
1922    /// ```
1923    ///
1924    /// # Security
1925    ///
1926    /// By default, connections to localhost and private networks (RFC1918) are blocked.
1927    /// Use [`NetConfig::allow_localhost`] or [`NetConfig::permissive`] for testing.
1928    #[must_use]
1929    pub fn with_network(mut self, config: crate::net::NetConfig) -> Self {
1930        self.net_config = Some(config);
1931        self
1932    }
1933
1934    /// Add a secret that will be substituted at the network boundary.
1935    ///
1936    /// The sandbox will receive a placeholder via environment variable,
1937    /// and the real value will be injected only when making HTTP requests
1938    /// to allowed hosts.
1939    ///
1940    /// Placeholders are automatically scrubbed from stdout/stderr/files to
1941    /// prevent leakage (see [`scrub_stdout`](Self::scrub_stdout),
1942    /// [`scrub_stderr`](Self::scrub_stderr), [`scrub_files`](Self::scrub_files)).
1943    ///
1944    /// # Arguments
1945    ///
1946    /// * `name` - Environment variable name (e.g., "OPENAI_API_KEY")
1947    /// * `value` - The real secret value
1948    /// * `allowed_hosts` - Host patterns where this secret can be used.
1949    ///   Supports wildcards: `*.example.com`, `api.*.com`.
1950    ///
1951    /// # ⚠️ Important: `allowed_hosts` Behavior
1952    ///
1953    /// - **Empty `allowed_hosts`**: Falls back to `NetConfig.allowed_hosts`. If that
1954    ///   is also empty, the secret can be sent to **ANY host** (subject to blocked_hosts).
1955    /// - **Always specify `allowed_hosts`** for production use to prevent accidental
1956    ///   exfiltration to unauthorized hosts.
1957    ///
1958    /// # Security
1959    ///
1960    /// - Python code only sees a placeholder like `ERYX_SECRET_PLACEHOLDER_abc123`
1961    /// - Real value is substituted transparently when making HTTP requests
1962    /// - Host checks use the TCP connection target, NOT the HTTP Host header (prevents spoofing)
1963    /// - Placeholders are scrubbed from all outputs by default
1964    /// - Secrets are ephemeral (regenerated on each sandbox creation)
1965    ///
1966    /// # Example
1967    ///
1968    /// ```rust,ignore
1969    /// let sandbox = Sandbox::embedded()
1970    ///     .with_secret("OPENAI_API_KEY", "sk-real-key", vec!["api.openai.com"])
1971    ///     .with_network(NetConfig::default().allow_host("api.openai.com"))
1972    ///     .build()?;
1973    ///
1974    /// // Python code:
1975    /// // key = os.environ["OPENAI_API_KEY"]  # Gets placeholder
1976    /// // requests.get("https://api.openai.com", headers={"Authorization": f"Bearer {key}"})
1977    /// // # Real key is injected transparently
1978    /// ```
1979    #[must_use]
1980    pub fn with_secret(
1981        mut self,
1982        name: impl Into<String>,
1983        value: impl Into<String>,
1984        allowed_hosts: Vec<String>,
1985    ) -> Self {
1986        let name = name.into();
1987        let value = value.into();
1988        let placeholder = crate::secrets::generate_placeholder(&name);
1989
1990        self.secrets.insert(
1991            name.clone(),
1992            crate::secrets::SecretConfig {
1993                real_value: value,
1994                placeholder: placeholder.clone(),
1995                allowed_hosts,
1996            },
1997        );
1998
1999        // Set placeholder as environment variable via preamble
2000        // TODO: Find proper way to set env vars in executor
2001        let env_code = format!("import os\nos.environ[{:?}] = {:?}\n", name, placeholder);
2002        self.preamble.push_str(&env_code);
2003
2004        self
2005    }
2006
2007    /// Control stdout scrubbing (default: All when secrets configured).
2008    ///
2009    /// Accepts `bool` (for convenience) or `OutputScrubPolicy` (for future extensibility).
2010    ///
2011    /// When enabled, secret placeholders are replaced with `[REDACTED]` in stdout.
2012    ///
2013    /// # Example
2014    ///
2015    /// ```rust,ignore
2016    /// .scrub_stdout(true)   // Enable scrubbing (default)
2017    /// .scrub_stdout(false)  // Disable for debugging
2018    /// ```
2019    #[must_use]
2020    pub fn scrub_stdout(mut self, policy: impl Into<crate::secrets::OutputScrubPolicy>) -> Self {
2021        self.scrub_stdout = policy.into();
2022        self
2023    }
2024
2025    /// Control stderr scrubbing (default: All when secrets configured).
2026    ///
2027    /// Accepts `bool` (for convenience) or `OutputScrubPolicy` (for future extensibility).
2028    ///
2029    /// When enabled, secret placeholders are replaced with `[REDACTED]` in stderr.
2030    #[must_use]
2031    pub fn scrub_stderr(mut self, policy: impl Into<crate::secrets::OutputScrubPolicy>) -> Self {
2032        self.scrub_stderr = policy.into();
2033        self
2034    }
2035
2036    /// Control scrubbing of the structured `result` channel (default: `false`).
2037    ///
2038    /// Unlike stdout/stderr — which are scrubbed by default because they tend to be
2039    /// surfaced to humans/LLMs — the `result` (and `result_error`) field is a
2040    /// programmatic side channel, so secret-placeholder scrubbing is opt-in. When
2041    /// enabled, placeholders in [`ExecuteResult::result`] and
2042    /// [`ExecuteResult::result_error`] are replaced with `[REDACTED]`.
2043    #[must_use]
2044    pub fn scrub_result(mut self, enabled: bool) -> Self {
2045        self.scrub_result = enabled;
2046        self
2047    }
2048
2049    /// Control file scrubbing (default: All when secrets configured).
2050    ///
2051    /// Accepts `bool` or `FileScrubPolicy` for forward compatibility.
2052    ///
2053    /// When enabled, secret placeholders are replaced with `[REDACTED]` when
2054    /// writing files to the VFS.
2055    ///
2056    /// # Example
2057    ///
2058    /// ```rust,ignore
2059    /// // Phase 1: Simple boolean
2060    /// .scrub_files(true)
2061    ///
2062    /// // Phase 2: Path-based policies (future)
2063    /// .scrub_files(FileScrubPolicy::except(vec!["/tmp/cache/*"]))
2064    /// ```
2065    #[must_use]
2066    pub fn scrub_files(mut self, policy: impl Into<crate::secrets::FileScrubPolicy>) -> Self {
2067        self.scrub_files = policy.into();
2068        self
2069    }
2070
2071    /// Add a host filesystem volume mount.
2072    ///
2073    /// Mounts a host directory into the sandbox at the specified guest path,
2074    /// using cap-std for capability-based security.
2075    #[cfg(feature = "vfs")]
2076    #[must_use]
2077    pub fn with_volume(mut self, volume: crate::session::VolumeMount) -> Self {
2078        self.volumes.push(volume);
2079        self
2080    }
2081
2082    /// Add multiple host filesystem volume mounts.
2083    #[cfg(feature = "vfs")]
2084    #[must_use]
2085    pub fn with_volumes(
2086        mut self,
2087        volumes: impl IntoIterator<Item = crate::session::VolumeMount>,
2088    ) -> Self {
2089        self.volumes.extend(volumes);
2090        self
2091    }
2092
2093    /// Set the path to additional Python packages directory.
2094    ///
2095    /// The directory will be mounted at `/site-packages` inside the WASM sandbox
2096    /// and added to Python's import path.
2097    #[must_use]
2098    pub fn with_site_packages(mut self, path: impl Into<std::path::PathBuf>) -> Self {
2099        self.python_site_packages_path = Some(path.into());
2100        self
2101    }
2102
2103    /// Add a Python package from a wheel (.whl) or tar.gz archive.
2104    ///
2105    /// The package format is auto-detected from the file extension:
2106    /// - `.whl` - Standard Python wheel (zip archive)
2107    /// - `.tar.gz`, `.tgz` - Tarball (used by wasi-wheels)
2108    /// - Directory - Used directly without extraction
2109    ///
2110    /// # Pure Python packages
2111    ///
2112    /// For pure-Python packages (no `.so` files), you can use `with_embedded_runtime()`:
2113    ///
2114    /// ```rust,ignore
2115    /// let sandbox = Sandbox::builder()
2116    ///     .with_embedded_runtime()
2117    ///     .with_package("/path/to/requests-2.31.0-py3-none-any.whl")?
2118    ///     .build()?;
2119    /// ```
2120    ///
2121    /// # Packages with native extensions
2122    ///
2123    /// For packages containing native extensions (like numpy), the extensions are
2124    /// automatically registered for late-linking. A cache is set up automatically
2125    /// at `$TMPDIR/eryx-cache` for fast subsequent sandbox creations:
2126    ///
2127    /// ```rust,ignore
2128    /// let sandbox = Sandbox::builder()
2129    ///     .with_package("/path/to/numpy-wasi.tar.gz")?
2130    ///     .build()?;  // Caching is automatic!
2131    /// ```
2132    ///
2133    /// # Errors
2134    ///
2135    /// Returns an error if:
2136    /// - The package format cannot be detected
2137    /// - The archive cannot be read or extracted
2138    pub fn with_package(mut self, path: impl AsRef<std::path::Path>) -> Result<Self, Error> {
2139        let package = crate::package::ExtractedPackage::from_path(path)?;
2140
2141        tracing::info!(
2142            name = %package.name,
2143            has_native_extensions = package.has_native_extensions,
2144            "Loaded package"
2145        );
2146
2147        // Check for incompatible configuration
2148        #[cfg(not(feature = "native-extensions"))]
2149        if package.has_native_extensions {
2150            return Err(Error::Initialization(format!(
2151                "Package '{}' contains native extensions but the 'native-extensions' feature is not enabled. \
2152                 Either use a pure-Python package or enable the 'native-extensions' feature.",
2153                package.name
2154            )));
2155        }
2156
2157        // Store the extracted package - native extensions will be registered at build()
2158        // time when we know the mount index for computing dlopen paths
2159        self.packages.push(package);
2160
2161        Ok(self)
2162    }
2163
2164    /// Load a Python package from raw bytes.
2165    ///
2166    /// This is useful when downloading packages from URLs. The format must be
2167    /// specified explicitly since it cannot be detected from bytes alone.
2168    ///
2169    /// # Example
2170    ///
2171    /// ```rust,ignore
2172    /// use eryx::{Sandbox, PackageFormat};
2173    ///
2174    /// // Download a package (using your preferred HTTP client)
2175    /// let bytes = reqwest::get("https://example.com/numpy-wasi.tar.gz")
2176    ///     .await?
2177    ///     .bytes()
2178    ///     .await?;
2179    ///
2180    /// let sandbox = Sandbox::builder()
2181    ///     .with_package_bytes(&bytes, PackageFormat::TarGz, "numpy")?
2182    ///     .build()?;
2183    /// ```
2184    ///
2185    /// # Arguments
2186    ///
2187    /// * `bytes` - The raw package bytes
2188    /// * `format` - The package format (Wheel or TarGz)
2189    /// * `name_hint` - Package name hint used if detection fails (e.g., "numpy")
2190    ///
2191    /// # Errors
2192    ///
2193    /// Returns an error if:
2194    /// - The format is `Directory` (not supported for bytes)
2195    /// - The archive cannot be read or extracted
2196    pub fn with_package_bytes(
2197        mut self,
2198        bytes: &[u8],
2199        format: crate::package::PackageFormat,
2200        name_hint: impl Into<String>,
2201    ) -> Result<Self, Error> {
2202        let package = crate::package::ExtractedPackage::from_bytes(bytes, format, name_hint)?;
2203
2204        tracing::info!(
2205            name = %package.name,
2206            has_native_extensions = package.has_native_extensions,
2207            "Loaded package from bytes"
2208        );
2209
2210        // Check for incompatible configuration
2211        #[cfg(not(feature = "native-extensions"))]
2212        if package.has_native_extensions {
2213            return Err(Error::Initialization(format!(
2214                "Package '{}' contains native extensions but the 'native-extensions' feature is not enabled. \
2215                 Either use a pure-Python package or enable the 'native-extensions' feature.",
2216                package.name
2217            )));
2218        }
2219
2220        // Store the extracted package - native extensions will be registered at build()
2221        // time when we know the mount index for computing dlopen paths
2222        self.packages.push(package);
2223
2224        Ok(self)
2225    }
2226}
2227
2228// Build only available when BOTH runtime AND stdlib are configured
2229impl SandboxBuilder<state::Has, state::Has> {
2230    /// Convert this builder into a reusable factory closure.
2231    ///
2232    /// The factory captures the wasm source and stdlib path so it can create
2233    /// fresh sandboxes on demand. Per-request settings (callbacks, packages,
2234    /// resource limits, etc.) are NOT captured -- each sandbox starts with
2235    /// defaults for those.
2236    ///
2237    /// This is used by [`SandboxPool`](crate::SandboxPool) to recreate sandboxes
2238    /// since `build()` consumes the builder.
2239    pub(crate) fn into_factory(self) -> Box<dyn Fn() -> Result<Sandbox, Error> + Send + Sync> {
2240        let wasm_source = self.wasm_source;
2241        let stdlib_path = self.python_stdlib_path;
2242
2243        Box::new(move || {
2244            // Reconstruct a builder using the public API based on the captured config.
2245            let builder = match &wasm_source {
2246                #[cfg(feature = "embedded")]
2247                WasmSource::EmbeddedRuntime => Sandbox::embedded(),
2248                #[cfg(any(feature = "embedded", feature = "preinit"))]
2249                WasmSource::PrecompiledFile(path) => {
2250                    let stdlib = stdlib_path.as_ref().ok_or_else(|| {
2251                        Error::Initialization("stdlib path required for precompiled file".into())
2252                    })?;
2253                    // SAFETY: The original builder was already constructed with unsafe
2254                    // by the caller, who vouched for the .cwasm file's trustworthiness.
2255                    #[allow(unsafe_code)]
2256                    unsafe {
2257                        Sandbox::builder()
2258                            .with_precompiled_file(path)
2259                            .with_python_stdlib(stdlib)
2260                    }
2261                }
2262                WasmSource::File(path) => {
2263                    let stdlib = stdlib_path
2264                        .as_ref()
2265                        .ok_or_else(|| Error::Initialization("stdlib path required".into()))?;
2266                    Sandbox::builder()
2267                        .with_wasm_file(path)
2268                        .with_python_stdlib(stdlib)
2269                }
2270                _ => {
2271                    return Err(Error::Initialization(
2272                        "unsupported wasm source for pool factory".into(),
2273                    ));
2274                }
2275            };
2276            builder.build()
2277        })
2278    }
2279
2280    /// Build the sandbox.
2281    ///
2282    /// # Errors
2283    ///
2284    /// Returns an error if:
2285    /// - No WASM component was specified and no default is available
2286    /// - The WASM component cannot be loaded
2287    /// - The WebAssembly runtime fails to initialize
2288    ///
2289    /// # Native Extensions
2290    ///
2291    /// If native extensions are registered (via `with_native_extension()` or
2292    /// `with_package()` with `.so` files), late-linking is used automatically.
2293    /// This overrides any `with_embedded_runtime()` setting since native
2294    /// extensions must be linked into the runtime.
2295    #[allow(unused_mut)] // mut needed when native-extensions feature is enabled
2296    #[tracing::instrument(
2297        name = "SandboxBuilder::build",
2298        skip(self),
2299        fields(
2300            callbacks = self.callbacks.len(),
2301            packages = self.packages.len(),
2302            has_trace_handler = self.trace_handler.is_some(),
2303            timeout = ?self.resource_limits.execution_timeout,
2304            fuel_limit = ?self.resource_limits.max_fuel,
2305        )
2306    )]
2307    pub fn build(mut self) -> Result<Sandbox, Error> {
2308        // First, compute mount indices and register native extensions from packages
2309        // with correct dlopen paths. Mount index 0 is reserved for explicit site-packages.
2310        #[cfg(feature = "native-extensions")]
2311        {
2312            let start_index = if self.python_site_packages_path.is_some() {
2313                1
2314            } else {
2315                0
2316            };
2317            for (pkg_idx, package) in self.packages.iter().enumerate() {
2318                let mount_index = start_index + pkg_idx;
2319                for ext in &package.native_extensions {
2320                    let dlopen_path =
2321                        format!("/site-packages-{}/{}", mount_index, ext.relative_path);
2322                    self.native_extensions
2323                        .push(eryx_runtime::linker::NativeExtension::new(
2324                            dlopen_path,
2325                            ext.bytes.clone(),
2326                        ));
2327                }
2328            }
2329        }
2330
2331        // Set up default cache for native extensions if none specified
2332        // This avoids re-linking on every sandbox creation
2333        #[cfg(all(feature = "native-extensions", feature = "embedded"))]
2334        if !self.native_extensions.is_empty()
2335            && self.filesystem_cache.is_none()
2336            && self.cache.is_none()
2337        {
2338            let default_cache_dir = std::env::temp_dir().join("eryx-cache");
2339            if let Ok(cache) = crate::cache::FilesystemCache::new(&default_cache_dir) {
2340                tracing::debug!(path = %default_cache_dir.display(), "Using default cache directory");
2341                self.filesystem_cache = Some(cache.clone());
2342                self.cache = Some(Arc::new(cache));
2343            }
2344        }
2345
2346        // If native extensions are specified, use late-linking to create the component.
2347        // This OVERRIDES any wasm_source setting (including embedded runtime) because
2348        // native extensions must be linked into the runtime at this point.
2349        #[cfg(feature = "native-extensions")]
2350        let executor = if !self.native_extensions.is_empty() {
2351            // Warn if user explicitly set embedded runtime - it will be ignored
2352            #[cfg(feature = "embedded")]
2353            if matches!(self.wasm_source, WasmSource::EmbeddedRuntime) {
2354                tracing::info!(
2355                    "Native extensions detected - using late-linking instead of embedded runtime"
2356                );
2357            }
2358            self.build_executor_with_extensions()?
2359        } else {
2360            self.build_executor_from_source()?
2361        };
2362
2363        #[cfg(not(feature = "native-extensions"))]
2364        let executor = self.build_executor_from_source()?;
2365
2366        // Determine stdlib path: explicit > embedded > auto-detect > none
2367        #[cfg(feature = "embedded")]
2368        let stdlib_path = self
2369            .python_stdlib_path
2370            .clone()
2371            .or_else(|| {
2372                crate::embedded::EmbeddedResources::get()
2373                    .ok()
2374                    .map(|r| r.stdlib_path.clone())
2375            })
2376            .or_else(find_python_stdlib);
2377
2378        #[cfg(not(feature = "embedded"))]
2379        let stdlib_path = self.python_stdlib_path.clone().or_else(find_python_stdlib);
2380
2381        // Collect all site-packages paths: explicit path first, then package paths
2382        let mut site_packages_paths = Vec::new();
2383        if let Some(explicit_path) = self.python_site_packages_path.clone() {
2384            site_packages_paths.push(explicit_path);
2385        }
2386        for package in &self.packages {
2387            site_packages_paths.push(package.python_path.clone());
2388        }
2389
2390        // Apply Python stdlib path if available
2391        let executor = if let Some(stdlib) = stdlib_path {
2392            executor.with_python_stdlib(&stdlib)
2393        } else {
2394            executor
2395        };
2396
2397        // Apply all site-packages paths
2398        let executor = site_packages_paths
2399            .into_iter()
2400            .fold(executor, |exec, path| exec.with_site_packages(&path));
2401
2402        // Apply the configured result-capture variable name.
2403        let executor = executor.with_result_variable(self.result_variable);
2404
2405        Ok(Sandbox {
2406            executor: Arc::new(executor),
2407            callbacks: Arc::new(self.callbacks),
2408            preamble: self.preamble,
2409            type_stubs: self.type_stubs,
2410            trace_handler: self.trace_handler,
2411            collect_trace: self.collect_trace,
2412            output_handler: self.output_handler,
2413            resource_limits: self.resource_limits,
2414            net_config: self.net_config,
2415            secrets: self.secrets,
2416            scrub_stdout: self.scrub_stdout,
2417            scrub_stderr: self.scrub_stderr,
2418            scrub_result: self.scrub_result,
2419            scrub_files: self.scrub_files,
2420            #[cfg(feature = "vfs")]
2421            volumes: self.volumes,
2422            #[cfg(feature = "vfs")]
2423            vfs_storage: None,
2424            _packages: self.packages,
2425            replay_journal: self.replay_journal,
2426        })
2427    }
2428
2429    /// Build executor from the configured WASM source.
2430    fn build_executor_from_source(&self) -> Result<PythonExecutor, Error> {
2431        let executor = match &self.wasm_source {
2432            WasmSource::Bytes(bytes) => PythonExecutor::from_binary(bytes)?,
2433            WasmSource::File(path) => PythonExecutor::from_file(path)?,
2434
2435            #[cfg(any(feature = "embedded", feature = "preinit"))]
2436            WasmSource::PrecompiledBytes(bytes) => {
2437                // SAFETY: User is responsible for only using trusted pre-compiled bytes.
2438                // The `with_precompiled_bytes` method is already marked unsafe, so the
2439                // caller has acknowledged this responsibility.
2440                #[allow(unsafe_code)]
2441                unsafe {
2442                    Self::load_precompiled(bytes, None)?
2443                }
2444            }
2445
2446            #[cfg(any(feature = "embedded", feature = "preinit"))]
2447            WasmSource::PrecompiledArtifact(artifact) => {
2448                // SAFETY: User is responsible for only using trusted pre-compiled bytes.
2449                // The `with_precompiled_artifact` method is already marked unsafe, so
2450                // the caller has acknowledged this responsibility.
2451                #[allow(unsafe_code)]
2452                unsafe {
2453                    Self::load_precompiled(artifact.as_bytes(), artifact.cache_key())?
2454                }
2455            }
2456
2457            #[cfg(any(feature = "embedded", feature = "preinit"))]
2458            WasmSource::PrecompiledFile(path) => {
2459                // SAFETY: User is responsible for only using trusted pre-compiled files.
2460                // The `with_precompiled_file` method is already marked unsafe, so the
2461                // caller has acknowledged this responsibility.
2462                #[allow(unsafe_code)]
2463                unsafe {
2464                    PythonExecutor::from_precompiled_file(path)?
2465                }
2466            }
2467
2468            #[cfg(feature = "embedded")]
2469            WasmSource::EmbeddedRuntime => {
2470                // Use the optimized path that leverages InstancePreCache
2471                PythonExecutor::from_embedded_runtime()?
2472            }
2473
2474            WasmSource::None => {
2475                // If embedded feature is enabled, use it automatically as the default
2476                #[cfg(feature = "embedded")]
2477                {
2478                    tracing::debug!("No WASM source specified, using embedded runtime");
2479                    // Use the optimized path that leverages InstancePreCache
2480                    PythonExecutor::from_embedded_runtime()?
2481                }
2482
2483                #[cfg(not(feature = "embedded"))]
2484                {
2485                    let msg = "No WASM component specified. Use with_wasm_bytes() or with_wasm_file(). \
2486                               Or enable the `embedded` feature for automatic runtime loading.";
2487
2488                    return Err(Error::Initialization(msg.to_string()));
2489                }
2490            }
2491        };
2492
2493        Ok(executor)
2494    }
2495
2496    /// Load a pre-compiled component, using the global [`InstancePreCache`]
2497    /// when a cache key is configured.
2498    ///
2499    /// # Safety
2500    ///
2501    /// Caller guarantees the pre-compiled bytes are trusted and were created
2502    /// by `PythonExecutor::precompile()` with a compatible engine configuration.
2503    #[cfg(any(feature = "embedded", feature = "preinit"))]
2504    #[allow(unsafe_code)]
2505    unsafe fn load_precompiled(
2506        bytes: &[u8],
2507        cache_key: Option<&crate::cache::CacheKey>,
2508    ) -> Result<PythonExecutor, Error> {
2509        #[cfg(feature = "embedded")]
2510        if let Some(key) = cache_key {
2511            // SAFETY: Caller guarantees the pre-compiled bytes are trusted.
2512            #[allow(unsafe_code)]
2513            return unsafe { PythonExecutor::from_precompiled_with_key(bytes, key.clone()) };
2514        }
2515        // Without `embedded` there is no cache to consult; the key is unused.
2516        #[cfg(not(feature = "embedded"))]
2517        let _ = cache_key;
2518
2519        // SAFETY: Caller guarantees the pre-compiled bytes are trusted.
2520        #[allow(unsafe_code)]
2521        unsafe {
2522            PythonExecutor::from_precompiled(bytes)
2523        }
2524    }
2525
2526    /// Build executor with native extensions, using cache if available.
2527    ///
2528    /// When a cache is configured and the `embedded` feature is enabled,
2529    /// this will:
2530    /// 1. Check the cache for a pre-compiled component
2531    /// 2. If found, load from cache (fast path)
2532    /// 3. If not found, link extensions, pre-compile, cache, and return
2533    #[cfg(feature = "native-extensions")]
2534    fn build_executor_with_extensions(&self) -> Result<PythonExecutor, Error> {
2535        #[cfg(feature = "embedded")]
2536        use crate::cache::{CacheKey, InstancePreCache};
2537
2538        #[cfg(feature = "embedded")]
2539        let cache_key = CacheKey::from_extensions(&self.native_extensions);
2540
2541        // Tier 1: Check InstancePreCache first (fastest - just Clone)
2542        #[cfg(feature = "embedded")]
2543        if let Some(instance_pre) = InstancePreCache::global().get(&cache_key) {
2544            tracing::debug!(
2545                key = %cache_key.to_hex(),
2546                "instance_pre cache hit - returning cached executor"
2547            );
2548            return PythonExecutor::from_cached_instance_pre(instance_pre);
2549        }
2550
2551        // Tier 2: Try filesystem cache (mmap-based, faster than bytes)
2552        #[cfg(feature = "embedded")]
2553        if let Some(fs_cache) = &self.filesystem_cache
2554            && let Some(path) = fs_cache.get_path(&cache_key)
2555        {
2556            tracing::debug!(
2557                key = %cache_key.to_hex(),
2558                path = %path.display(),
2559                "component cache hit - loading via mmap"
2560            );
2561            // SAFETY: The cached pre-compiled file was created by us (from
2562            // `PythonExecutor::precompile()`) in a previous call. We trust our
2563            // own cache directory. If the cache is corrupted or tampered with,
2564            // wasmtime will detect it during deserialization.
2565            // Use with_key to populate InstancePreCache for future calls.
2566            #[allow(unsafe_code)]
2567            return unsafe { PythonExecutor::from_precompiled_file_with_key(&path, cache_key) };
2568        }
2569
2570        // Tier 2 (continued): Fall back to in-memory byte cache (for InMemoryCache users)
2571        #[cfg(feature = "embedded")]
2572        if let Some(cache) = &self.cache {
2573            if let Some(precompiled) = cache.get(&cache_key) {
2574                tracing::debug!(
2575                    key = %cache_key.to_hex(),
2576                    "component cache hit - loading from bytes"
2577                );
2578                // Load and populate InstancePreCache for future calls
2579                #[allow(unsafe_code)]
2580                let executor = unsafe { PythonExecutor::from_precompiled(&precompiled) }?;
2581                InstancePreCache::global().put(cache_key, executor.instance_pre().clone());
2582                return Ok(executor);
2583            }
2584            tracing::debug!(
2585                key = %cache_key.to_hex(),
2586                "component cache miss - will link and compile"
2587            );
2588        }
2589
2590        // Cache miss or no cache - link the component
2591        let component_bytes =
2592            eryx_runtime::linker::link_with_extensions(&self.native_extensions)
2593                .map_err(|e| Error::Initialization(format!("late-linking failed: {e}")))?;
2594
2595        // Pre-compile and cache if available
2596        #[cfg(feature = "embedded")]
2597        if let Some(cache) = &self.cache {
2598            let precompiled = PythonExecutor::precompile(&component_bytes)?;
2599
2600            // Cache the pre-compiled bytes
2601            if let Err(e) = cache.put(&cache_key, precompiled.clone()) {
2602                tracing::warn!(
2603                    error = %e,
2604                    "failed to cache pre-compiled component"
2605                );
2606            } else {
2607                tracing::debug!(
2608                    key = %cache_key.to_hex(),
2609                    size = precompiled.len(),
2610                    "cached pre-compiled component"
2611                );
2612            }
2613
2614            // Load from pre-compiled bytes and populate InstancePreCache
2615            // SAFETY: We just created these bytes from `precompile()` above.
2616            #[allow(unsafe_code)]
2617            let executor = unsafe { PythonExecutor::from_precompiled(&precompiled) }?;
2618            InstancePreCache::global().put(cache_key, executor.instance_pre().clone());
2619            return Ok(executor);
2620        }
2621
2622        // No cache or embedded feature - create executor directly from linked bytes
2623        PythonExecutor::from_binary(&component_bytes)
2624    }
2625}
2626
2627/// Result of a replay-aware execution via [`Sandbox::execute_with_journal`].
2628///
2629/// The [`journal`](Self::journal) is always present — even when
2630/// [`result`](Self::result) is an error or the run suspended — so a later
2631/// resubmission can replay every callback that completed.
2632#[derive(Debug)]
2633pub struct ReplayOutcome {
2634    /// The execution result, exactly as [`Sandbox::execute`] would return it.
2635    ///
2636    /// When [`suspended`](Self::suspended) is `Some`, this is `Err` — the guest
2637    /// was halted (its fuel poisoned) the instant the callback suspended, so
2638    /// there is no normal output. Callers should branch on `suspended` first and
2639    /// treat this error as the expected consequence of a suspension rather than a
2640    /// failure.
2641    pub result: Result<ExecuteResult, Error>,
2642    /// The callback journal recorded during this run, in initiation order. The
2643    /// suspended callback itself is *not* recorded, so it re-runs live on resume.
2644    pub journal: CallbackJournal,
2645    /// How many callbacks were served from the previous journal (cache hits).
2646    pub replayed_callbacks: u32,
2647    /// Set when a callback requested suspension via
2648    /// [`CallbackError::Suspend`](crate::CallbackError::Suspend). Carries the
2649    /// callback name, arguments, and opaque reason so the caller can decide what
2650    /// to wait for before resuming with [`journal`](Self::journal).
2651    pub suspended: Option<SuspendedCallback>,
2652}
2653
2654/// Result of executing Python code in the sandbox.
2655#[derive(Debug, Clone)]
2656#[non_exhaustive]
2657pub struct ExecuteResult {
2658    /// Complete stdout output (also streamed via `OutputHandler` if configured).
2659    pub stdout: String,
2660    /// Complete stderr output (also streamed via `OutputHandler` if configured).
2661    pub stderr: String,
2662    /// Collected trace events (also streamed via `TraceHandler` if configured).
2663    pub trace: Vec<TraceEvent>,
2664    /// JSON-serialized value of the script's result variable (default name
2665    /// `result`, configurable via [`SandboxBuilder::with_result_variable`]), or
2666    /// `None` if the variable was not set.
2667    ///
2668    /// The variable is consumed (cleared from the namespace) after each execution,
2669    /// so in a persistent session a later run that does not set it reports `None`
2670    /// rather than re-reporting a stale value.
2671    pub result: Option<String>,
2672    /// Why result capture failed (e.g. the value was not JSON-serializable), or
2673    /// `None` when capture succeeded or no result variable was set.
2674    pub result_error: Option<String>,
2675    /// Execution statistics.
2676    pub stats: ExecuteStats,
2677}
2678
2679/// Statistics about sandbox execution.
2680#[derive(Debug, Clone)]
2681#[non_exhaustive]
2682pub struct ExecuteStats {
2683    /// Total execution time.
2684    pub duration: Duration,
2685    /// Number of callback invocations.
2686    pub callback_invocations: u32,
2687    /// Peak memory usage in bytes (if available).
2688    pub peak_memory_bytes: Option<u64>,
2689    /// Fuel consumed during execution (if fuel tracking is enabled).
2690    ///
2691    /// This measures the number of WASM instructions executed. The value
2692    /// is always present when the engine has fuel consumption enabled,
2693    /// regardless of whether a fuel limit was set.
2694    pub fuel_consumed: Option<u64>,
2695}
2696
2697/// Resource limits for sandbox execution.
2698///
2699/// Start from [`ResourceLimits::default()`] (or [`ResourceLimits::unlimited()`])
2700/// and adjust with the `with_*` methods:
2701///
2702/// ```rust
2703/// use std::time::Duration;
2704/// use eryx::ResourceLimits;
2705///
2706/// let limits = ResourceLimits::default()
2707///     .with_execution_timeout(Duration::from_secs(5))
2708///     .with_max_memory_bytes(64 * 1024 * 1024)
2709///     .with_max_fuel(None); // disable a single limit
2710/// ```
2711///
2712/// This type is `#[non_exhaustive]`: new limits get added as new things become
2713/// boundable, so it cannot be built with a struct literal from outside the
2714/// crate. Its fields stay public, so reading them - and mutating them on a value
2715/// you own - both still work.
2716#[derive(Debug, Clone)]
2717#[non_exhaustive]
2718pub struct ResourceLimits {
2719    /// Maximum execution time for the entire script.
2720    pub execution_timeout: Option<Duration>,
2721    /// Maximum time for a single callback invocation.
2722    pub callback_timeout: Option<Duration>,
2723    /// Maximum memory usage in bytes.
2724    pub max_memory_bytes: Option<u64>,
2725    /// Maximum number of callback invocations.
2726    pub max_callback_invocations: Option<u32>,
2727    /// Maximum fuel (instructions) allowed for execution.
2728    ///
2729    /// Fuel provides fine-grained, deterministic execution bounds at the
2730    /// instruction level. When fuel runs out, execution traps. This enables:
2731    /// - **Deterministic bounds**: Same code with same fuel limit always
2732    ///   executes the same number of instructions
2733    /// - **Fine-grained control**: Instruction-level granularity (vs. epoch's ~10ms)
2734    /// - **Billing/metering**: Track exactly how much "work" code performed
2735    ///
2736    /// When `None`, fuel is set to `u64::MAX` for tracking-only mode (fuel
2737    /// consumed is still reported in [`ExecuteStats`]).
2738    pub max_fuel: Option<u64>,
2739    /// Maximum total bytes the in-memory virtual filesystem may hold.
2740    ///
2741    /// VFS file contents live in *host* memory, outside
2742    /// [`max_memory_bytes`](Self::max_memory_bytes), and the script picks its own
2743    /// write offsets - so this is what stops a script from making the host
2744    /// allocate arbitrarily much via `f.seek(2**40); f.write(b'X')`. Scripts see
2745    /// the limit as `ENOSPC`.
2746    ///
2747    /// Only applies with the `vfs` feature, and only to the storage eryx creates
2748    /// itself; storage you construct and pass in carries its own limit (see
2749    /// `eryx::vfs::InMemoryStorage::with_max_bytes`). `None` uses the `eryx-vfs`
2750    /// default of 64 MiB.
2751    pub max_vfs_bytes: Option<u64>,
2752}
2753
2754impl Default for ResourceLimits {
2755    fn default() -> Self {
2756        Self {
2757            execution_timeout: Some(Duration::from_secs(30)),
2758            callback_timeout: Some(Duration::from_secs(10)),
2759            max_memory_bytes: Some(128 * 1024 * 1024), // 128 MB
2760            max_callback_invocations: Some(1000),
2761            max_fuel: None,      // Unlimited by default, but still tracked
2762            max_vfs_bytes: None, // eryx-vfs default (64 MiB)
2763        }
2764    }
2765}
2766
2767impl ResourceLimits {
2768    /// Limits with every bound disabled.
2769    ///
2770    /// Only for code you trust: scripts can then run indefinitely and consume
2771    /// memory without bound.
2772    ///
2773    /// [`max_vfs_bytes`](Self::max_vfs_bytes) is the exception and stays at its
2774    /// default. It bounds *host* memory rather than the guest, so clearing it
2775    /// would let a script abort the process rather than merely outstay its
2776    /// welcome; raise it explicitly if you need more room.
2777    #[must_use]
2778    pub fn unlimited() -> Self {
2779        Self {
2780            execution_timeout: None,
2781            callback_timeout: None,
2782            max_memory_bytes: None,
2783            max_callback_invocations: None,
2784            max_fuel: None,
2785            max_vfs_bytes: None,
2786        }
2787    }
2788
2789    /// Set the maximum execution time for the entire script.
2790    ///
2791    /// Pass `None` to remove the limit.
2792    #[must_use]
2793    pub fn with_execution_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
2794        self.execution_timeout = timeout.into();
2795        self
2796    }
2797
2798    /// Set the maximum time for a single callback invocation.
2799    ///
2800    /// Pass `None` to remove the limit.
2801    #[must_use]
2802    pub fn with_callback_timeout(mut self, timeout: impl Into<Option<Duration>>) -> Self {
2803        self.callback_timeout = timeout.into();
2804        self
2805    }
2806
2807    /// Set the maximum guest memory in bytes.
2808    ///
2809    /// Pass `None` to remove the limit.
2810    #[must_use]
2811    pub fn with_max_memory_bytes(mut self, bytes: impl Into<Option<u64>>) -> Self {
2812        self.max_memory_bytes = bytes.into();
2813        self
2814    }
2815
2816    /// Set the maximum number of callback invocations.
2817    ///
2818    /// Pass `None` to remove the limit.
2819    #[must_use]
2820    pub fn with_max_callback_invocations(mut self, count: impl Into<Option<u32>>) -> Self {
2821        self.max_callback_invocations = count.into();
2822        self
2823    }
2824
2825    /// Set the maximum fuel (WASM instructions) allowed for execution.
2826    ///
2827    /// Pass `None` for tracking-only mode, where fuel is still reported in
2828    /// [`ExecuteStats`] but never runs out.
2829    #[must_use]
2830    pub fn with_max_fuel(mut self, fuel: impl Into<Option<u64>>) -> Self {
2831        self.max_fuel = fuel.into();
2832        self
2833    }
2834
2835    /// Set the maximum total bytes the in-memory virtual filesystem may hold.
2836    ///
2837    /// Pass `None` to use the `eryx-vfs` default of 64 MiB. There is no way to
2838    /// remove this limit: see [`max_vfs_bytes`](Self::max_vfs_bytes).
2839    #[must_use]
2840    pub fn with_max_vfs_bytes(mut self, bytes: impl Into<Option<u64>>) -> Self {
2841        self.max_vfs_bytes = bytes.into();
2842        self
2843    }
2844}
2845
2846#[cfg(test)]
2847#[allow(clippy::unwrap_used, clippy::expect_used)]
2848mod tests {
2849    use super::*;
2850    use crate::callback::{CallbackError, TypedCallback};
2851    use crate::schema::JsonSchema;
2852    use serde::Deserialize;
2853    use serde_json::{Value, json};
2854    use std::future::Future;
2855    use std::pin::Pin;
2856
2857    // ==========================================================================
2858    // ResourceLimits tests
2859    // ==========================================================================
2860
2861    #[test]
2862    fn resource_limits_default_has_reasonable_values() {
2863        let limits = ResourceLimits::default();
2864
2865        // Should have execution timeout
2866        assert!(limits.execution_timeout.is_some());
2867        let exec_timeout = limits.execution_timeout.unwrap();
2868        assert!(exec_timeout >= Duration::from_secs(1));
2869        assert!(exec_timeout <= Duration::from_secs(300));
2870
2871        // Should have callback timeout
2872        assert!(limits.callback_timeout.is_some());
2873        let cb_timeout = limits.callback_timeout.unwrap();
2874        assert!(cb_timeout >= Duration::from_secs(1));
2875        assert!(cb_timeout <= Duration::from_secs(60));
2876
2877        // Should have memory limit
2878        assert!(limits.max_memory_bytes.is_some());
2879        let mem_limit = limits.max_memory_bytes.unwrap();
2880        assert!(mem_limit >= 1024 * 1024); // At least 1 MB
2881        assert!(mem_limit <= 1024 * 1024 * 1024); // At most 1 GB
2882
2883        // Should have callback invocation limit
2884        assert!(limits.max_callback_invocations.is_some());
2885        let cb_limit = limits.max_callback_invocations.unwrap();
2886        assert!(cb_limit >= 1);
2887    }
2888
2889    #[test]
2890    fn resource_limits_can_disable_all_limits() {
2891        let limits = ResourceLimits::unlimited();
2892
2893        assert!(limits.execution_timeout.is_none());
2894        assert!(limits.callback_timeout.is_none());
2895        assert!(limits.max_memory_bytes.is_none());
2896        assert!(limits.max_callback_invocations.is_none());
2897        assert!(limits.max_fuel.is_none());
2898        // Host-memory bound: `unlimited()` leaves it at the eryx-vfs default.
2899        assert!(limits.max_vfs_bytes.is_none());
2900    }
2901
2902    /// The `with_*` setters take `impl Into<Option<T>>`, so both a bare value and
2903    /// `None` have to work at the call site - that inference is the whole
2904    /// ergonomic case for the builder, and it is easy to break.
2905    #[test]
2906    fn resource_limits_builder_accepts_values_and_none() {
2907        let limits = ResourceLimits::default()
2908            .with_execution_timeout(Duration::from_secs(5))
2909            .with_callback_timeout(Duration::from_millis(500))
2910            .with_max_memory_bytes(64 * 1024 * 1024)
2911            .with_max_callback_invocations(10)
2912            .with_max_fuel(1_000_000)
2913            .with_max_vfs_bytes(8 * 1024 * 1024);
2914
2915        assert_eq!(limits.execution_timeout, Some(Duration::from_secs(5)));
2916        assert_eq!(limits.callback_timeout, Some(Duration::from_millis(500)));
2917        assert_eq!(limits.max_memory_bytes, Some(64 * 1024 * 1024));
2918        assert_eq!(limits.max_callback_invocations, Some(10));
2919        assert_eq!(limits.max_fuel, Some(1_000_000));
2920        assert_eq!(limits.max_vfs_bytes, Some(8 * 1024 * 1024));
2921
2922        let cleared = limits
2923            .with_execution_timeout(None)
2924            .with_callback_timeout(None)
2925            .with_max_memory_bytes(None)
2926            .with_max_callback_invocations(None)
2927            .with_max_fuel(None)
2928            .with_max_vfs_bytes(None);
2929
2930        assert!(cleared.execution_timeout.is_none());
2931        assert!(cleared.callback_timeout.is_none());
2932        assert!(cleared.max_memory_bytes.is_none());
2933        assert!(cleared.max_callback_invocations.is_none());
2934        assert!(cleared.max_fuel.is_none());
2935        assert!(cleared.max_vfs_bytes.is_none());
2936    }
2937
2938    #[test]
2939    fn resource_limits_can_set_custom_values() {
2940        let limits = ResourceLimits::default()
2941            .with_execution_timeout(Duration::from_secs(5))
2942            .with_callback_timeout(Duration::from_millis(500))
2943            .with_max_memory_bytes(64 * 1024 * 1024)
2944            .with_max_callback_invocations(10)
2945            .with_max_fuel(1_000_000)
2946            .with_max_vfs_bytes(8 * 1024 * 1024);
2947
2948        assert_eq!(limits.execution_timeout, Some(Duration::from_secs(5)));
2949        assert_eq!(limits.callback_timeout, Some(Duration::from_millis(500)));
2950        assert_eq!(limits.max_memory_bytes, Some(64 * 1024 * 1024));
2951        assert_eq!(limits.max_callback_invocations, Some(10));
2952        assert_eq!(limits.max_fuel, Some(1_000_000));
2953        assert_eq!(limits.max_vfs_bytes, Some(8 * 1024 * 1024));
2954    }
2955
2956    #[test]
2957    fn resource_limits_is_clone() {
2958        let limits = ResourceLimits::default();
2959        let cloned = limits.clone();
2960
2961        assert_eq!(limits.execution_timeout, cloned.execution_timeout);
2962        assert_eq!(limits.callback_timeout, cloned.callback_timeout);
2963        assert_eq!(limits.max_memory_bytes, cloned.max_memory_bytes);
2964        assert_eq!(
2965            limits.max_callback_invocations,
2966            cloned.max_callback_invocations
2967        );
2968        assert_eq!(limits.max_fuel, cloned.max_fuel);
2969    }
2970
2971    #[test]
2972    fn resource_limits_is_debug() {
2973        let limits = ResourceLimits::default();
2974        let debug = format!("{:?}", limits);
2975
2976        assert!(debug.contains("ResourceLimits"));
2977        assert!(debug.contains("execution_timeout"));
2978        assert!(debug.contains("callback_timeout"));
2979    }
2980
2981    #[test]
2982    fn resource_limits_partial_override() {
2983        // Common pattern: override just one limit
2984        let limits = ResourceLimits::default().with_max_callback_invocations(5);
2985
2986        assert_eq!(limits.max_callback_invocations, Some(5));
2987        // Others should be default
2988        assert!(limits.execution_timeout.is_some());
2989        assert!(limits.callback_timeout.is_some());
2990        assert!(limits.max_memory_bytes.is_some());
2991    }
2992
2993    // ==========================================================================
2994    // ExecuteResult tests
2995    // ==========================================================================
2996
2997    #[test]
2998    fn execute_result_is_debug() {
2999        let result = ExecuteResult {
3000            stdout: "Hello".to_string(),
3001            stderr: String::new(),
3002            trace: vec![],
3003            result: None,
3004            result_error: None,
3005            stats: ExecuteStats {
3006                duration: Duration::from_millis(100),
3007                callback_invocations: 5,
3008                peak_memory_bytes: Some(1024),
3009                fuel_consumed: Some(50000),
3010            },
3011        };
3012
3013        let debug = format!("{:?}", result);
3014        assert!(debug.contains("ExecuteResult"));
3015        assert!(debug.contains("Hello"));
3016    }
3017
3018    #[test]
3019    fn execute_result_is_clone() {
3020        let result = ExecuteResult {
3021            stdout: "Test output".to_string(),
3022            stderr: String::new(),
3023            trace: vec![],
3024            result: None,
3025            result_error: None,
3026            stats: ExecuteStats {
3027                duration: Duration::from_millis(50),
3028                callback_invocations: 2,
3029                peak_memory_bytes: Some(2048),
3030                fuel_consumed: Some(12345),
3031            },
3032        };
3033
3034        let cloned = result.clone();
3035        assert_eq!(cloned.stdout, "Test output");
3036        assert_eq!(cloned.stats.callback_invocations, 2);
3037        assert_eq!(cloned.stats.fuel_consumed, Some(12345));
3038    }
3039
3040    // ==========================================================================
3041    // ExecuteStats tests
3042    // ==========================================================================
3043
3044    #[test]
3045    fn execute_stats_is_debug() {
3046        let stats = ExecuteStats {
3047            duration: Duration::from_secs(1),
3048            callback_invocations: 10,
3049            peak_memory_bytes: Some(1024 * 1024),
3050            fuel_consumed: Some(100000),
3051        };
3052
3053        let debug = format!("{:?}", stats);
3054        assert!(debug.contains("ExecuteStats"));
3055        assert!(debug.contains("callback_invocations"));
3056        assert!(debug.contains("fuel_consumed"));
3057    }
3058
3059    #[test]
3060    fn execute_stats_is_clone() {
3061        let stats = ExecuteStats {
3062            duration: Duration::from_millis(250),
3063            callback_invocations: 3,
3064            peak_memory_bytes: None,
3065            fuel_consumed: Some(5000),
3066        };
3067
3068        let cloned = stats.clone();
3069        assert_eq!(cloned.duration, Duration::from_millis(250));
3070        assert_eq!(cloned.callback_invocations, 3);
3071        assert!(cloned.peak_memory_bytes.is_none());
3072        assert_eq!(cloned.fuel_consumed, Some(5000));
3073    }
3074
3075    #[test]
3076    fn execute_stats_peak_memory_can_be_none() {
3077        let stats = ExecuteStats {
3078            duration: Duration::from_millis(100),
3079            callback_invocations: 0,
3080            peak_memory_bytes: None,
3081            fuel_consumed: None,
3082        };
3083
3084        assert!(stats.peak_memory_bytes.is_none());
3085        assert!(stats.fuel_consumed.is_none());
3086    }
3087
3088    // ==========================================================================
3089    // SandboxBuilder tests
3090    // ==========================================================================
3091
3092    #[test]
3093    fn sandbox_builder_new_creates_default() {
3094        let builder = SandboxBuilder::new();
3095        let debug = format!("{:?}", builder);
3096
3097        assert!(debug.contains("SandboxBuilder"));
3098    }
3099
3100    #[test]
3101    fn sandbox_builder_default_equals_new() {
3102        let builder1 = SandboxBuilder::new();
3103        let builder2 = SandboxBuilder::default();
3104
3105        // Both should have same debug representation structure
3106        let debug1 = format!("{:?}", builder1);
3107        let debug2 = format!("{:?}", builder2);
3108
3109        // Both should contain SandboxBuilder
3110        assert!(debug1.contains("SandboxBuilder"));
3111        assert!(debug2.contains("SandboxBuilder"));
3112    }
3113
3114    #[test]
3115    fn sandbox_builder_is_debug() {
3116        let builder = SandboxBuilder::new();
3117        let debug = format!("{:?}", builder);
3118
3119        assert!(debug.contains("SandboxBuilder"));
3120        assert!(debug.contains("callbacks"));
3121        assert!(debug.contains("resource_limits"));
3122    }
3123
3124    // Test callbacks for builder tests
3125    #[derive(Deserialize, JsonSchema)]
3126    struct TestArgs {
3127        value: String,
3128    }
3129
3130    struct TestCallback;
3131
3132    impl TypedCallback for TestCallback {
3133        type Args = TestArgs;
3134
3135        fn name(&self) -> &str {
3136            "test"
3137        }
3138
3139        fn description(&self) -> &str {
3140            "A test callback"
3141        }
3142
3143        fn invoke_typed(
3144            &self,
3145            args: TestArgs,
3146        ) -> Pin<Box<dyn Future<Output = Result<Value, CallbackError>> + Send + '_>> {
3147            Box::pin(async move { Ok(json!({"value": args.value})) })
3148        }
3149    }
3150
3151    struct AnotherCallback;
3152
3153    impl TypedCallback for AnotherCallback {
3154        type Args = ();
3155
3156        fn name(&self) -> &str {
3157            "another"
3158        }
3159
3160        fn description(&self) -> &str {
3161            "Another callback"
3162        }
3163
3164        fn invoke_typed(
3165            &self,
3166            _args: (),
3167        ) -> Pin<Box<dyn Future<Output = Result<Value, CallbackError>> + Send + '_>> {
3168            Box::pin(async move { Ok(json!({})) })
3169        }
3170    }
3171
3172    /// Test that typestate prevents building without configuration.
3173    ///
3174    /// With typestate pattern, calling `build()` without configuring runtime and stdlib
3175    /// is a **compile-time error**, not a runtime error. This test documents the API design.
3176    ///
3177    /// The following code would NOT compile:
3178    /// ```compile_fail
3179    /// use eryx::Sandbox;
3180    /// let sandbox = Sandbox::builder().build(); // ERROR: build() not available
3181    /// ```
3182    #[test]
3183    fn sandbox_builder_typestate_prevents_unconfigured_build() {
3184        // This test verifies that the typestate pattern is in place.
3185        // The actual compile-time checking is documented above.
3186        //
3187        // We can verify that a fully-configured builder does have build():
3188        let _builder = SandboxBuilder::new()
3189            .with_wasm_bytes(vec![])
3190            .with_python_stdlib("/fake/path");
3191        // _builder.build() would work here (though fail at runtime due to invalid WASM)
3192    }
3193
3194    /// Test that sandbox creation succeeds with Sandbox::embedded().
3195    #[test]
3196    #[cfg(feature = "embedded")]
3197    fn sandbox_embedded_builds_successfully() {
3198        let result = Sandbox::embedded().build();
3199
3200        assert!(
3201            result.is_ok(),
3202            "Sandbox::embedded() should build successfully"
3203        );
3204    }
3205
3206    /// Test that with_embedded_runtime() transitions to fully-configured state.
3207    #[test]
3208    #[cfg(feature = "embedded")]
3209    fn sandbox_builder_with_embedded_runtime_builds() {
3210        let result = SandboxBuilder::new().with_embedded_runtime().build();
3211
3212        assert!(
3213            result.is_ok(),
3214            "with_embedded_runtime() should provide both runtime and stdlib"
3215        );
3216    }
3217
3218    #[test]
3219    fn sandbox_builder_with_callback_is_chainable() {
3220        // This should compile - testing the builder pattern
3221        let _builder = SandboxBuilder::new()
3222            .with_callback(TestCallback)
3223            .with_callback(AnotherCallback);
3224    }
3225
3226    #[test]
3227    fn sandbox_builder_with_callbacks_accepts_vec() {
3228        let callbacks: Vec<Box<dyn Callback>> =
3229            vec![Box::new(TestCallback), Box::new(AnotherCallback)];
3230
3231        let _builder = SandboxBuilder::new().with_callbacks(callbacks);
3232    }
3233
3234    #[test]
3235    fn sandbox_builder_with_resource_limits_is_chainable() {
3236        let limits = ResourceLimits::default().with_max_callback_invocations(5);
3237
3238        let _builder = SandboxBuilder::new().with_resource_limits(limits);
3239    }
3240
3241    #[test]
3242    fn sandbox_builder_with_wasm_bytes_accepts_vec() {
3243        // Just test that the builder accepts bytes - actual loading tested elsewhere
3244        let _builder = SandboxBuilder::new().with_wasm_bytes(vec![0u8; 10]);
3245    }
3246
3247    #[test]
3248    fn sandbox_builder_with_wasm_file_accepts_path() {
3249        let _builder = SandboxBuilder::new().with_wasm_file("/path/to/file.wasm");
3250        let _builder = SandboxBuilder::new().with_wasm_file(std::path::PathBuf::from("/path"));
3251    }
3252
3253    #[test]
3254    fn sandbox_builder_full_chain() {
3255        // Test the full builder pattern (won't build without valid WASM)
3256        let _builder = SandboxBuilder::new()
3257            .with_wasm_bytes(vec![])
3258            .with_callback(TestCallback)
3259            .with_callback(AnotherCallback)
3260            .with_resource_limits(ResourceLimits::default());
3261
3262        // Building will fail due to invalid WASM, but the chain works
3263    }
3264
3265    // ==========================================================================
3266    // Sandbox accessor tests (using a mock approach)
3267    // ==========================================================================
3268
3269    // Note: Full Sandbox tests require valid WASM and are in integration tests.
3270    // These test the accessor methods and types.
3271
3272    #[test]
3273    fn sandbox_builder_creates_sandbox_with_valid_wasm() {
3274        // This test would require valid WASM bytes, so we just verify
3275        // that the builder pattern compiles correctly
3276        let builder = Sandbox::builder()
3277            .with_wasm_bytes(vec![]) // Invalid, but tests the API
3278            .with_python_stdlib("/fake/stdlib") // Required for typestate
3279            .with_callback(TestCallback)
3280            .with_resource_limits(ResourceLimits::default().with_max_callback_invocations(100));
3281
3282        // Try to build - will fail due to invalid WASM
3283        let result = builder.build();
3284        assert!(result.is_err()); // Expected - invalid WASM bytes
3285    }
3286
3287    // ==========================================================================
3288    // WasmSource tests (internal)
3289    // ==========================================================================
3290
3291    #[test]
3292    fn wasm_source_default_is_none() {
3293        let source = WasmSource::default();
3294        assert!(matches!(source, WasmSource::None));
3295    }
3296
3297    // ==========================================================================
3298    // Edge case tests
3299    // ==========================================================================
3300
3301    #[test]
3302    fn resource_limits_zero_values() {
3303        // Zero limits should be representable (though may not be useful)
3304        let limits = ResourceLimits::default()
3305            .with_execution_timeout(Duration::ZERO)
3306            .with_callback_timeout(Duration::ZERO)
3307            .with_max_memory_bytes(0)
3308            .with_max_callback_invocations(0)
3309            .with_max_fuel(0)
3310            .with_max_vfs_bytes(0);
3311
3312        assert_eq!(limits.execution_timeout, Some(Duration::ZERO));
3313        assert_eq!(limits.max_callback_invocations, Some(0));
3314        assert_eq!(limits.max_fuel, Some(0));
3315    }
3316
3317    #[test]
3318    fn resource_limits_very_large_values() {
3319        let limits = ResourceLimits::default()
3320            .with_execution_timeout(Duration::from_secs(86400 * 365)) // 1 year
3321            .with_callback_timeout(Duration::from_secs(3600)) // 1 hour
3322            .with_max_memory_bytes(u64::MAX)
3323            .with_max_callback_invocations(u32::MAX)
3324            .with_max_fuel(u64::MAX)
3325            .with_max_vfs_bytes(u64::MAX);
3326
3327        assert_eq!(limits.max_callback_invocations, Some(u32::MAX));
3328        assert_eq!(limits.max_memory_bytes, Some(u64::MAX));
3329        assert_eq!(limits.max_fuel, Some(u64::MAX));
3330    }
3331
3332    #[test]
3333    fn execute_stats_zero_duration() {
3334        let stats = ExecuteStats {
3335            duration: Duration::ZERO,
3336            callback_invocations: 0,
3337            peak_memory_bytes: Some(0),
3338            fuel_consumed: Some(0),
3339        };
3340
3341        assert_eq!(stats.duration, Duration::ZERO);
3342        assert_eq!(stats.callback_invocations, 0);
3343        assert_eq!(stats.fuel_consumed, Some(0));
3344    }
3345
3346    #[test]
3347    fn execute_result_empty_stdout() {
3348        let result = ExecuteResult {
3349            stdout: String::new(),
3350            stderr: String::new(),
3351            trace: vec![],
3352            result: None,
3353            result_error: None,
3354            stats: ExecuteStats {
3355                duration: Duration::from_millis(1),
3356                callback_invocations: 0,
3357                peak_memory_bytes: None,
3358                fuel_consumed: None,
3359            },
3360        };
3361
3362        assert!(result.stdout.is_empty());
3363        assert!(result.trace.is_empty());
3364    }
3365
3366    #[test]
3367    fn execute_result_with_trace_events() {
3368        use crate::trace::{TraceEvent, TraceEventKind};
3369
3370        let result = ExecuteResult {
3371            stdout: "output".to_string(),
3372            stderr: String::new(),
3373            trace: vec![
3374                TraceEvent {
3375                    lineno: 1,
3376                    event: TraceEventKind::Line,
3377                    context: None,
3378                },
3379                TraceEvent {
3380                    lineno: 2,
3381                    event: TraceEventKind::Call {
3382                        function: "foo".to_string(),
3383                    },
3384                    context: None,
3385                },
3386            ],
3387            result: None,
3388            result_error: None,
3389            stats: ExecuteStats {
3390                duration: Duration::from_millis(100),
3391                callback_invocations: 1,
3392                peak_memory_bytes: Some(1024),
3393                fuel_consumed: Some(25000),
3394            },
3395        };
3396
3397        assert_eq!(result.trace.len(), 2);
3398        assert_eq!(result.trace[0].lineno, 1);
3399    }
3400
3401    // ==========================================================================
3402    // Unhappy path tests (explicit configuration errors)
3403    // ==========================================================================
3404
3405    #[test]
3406    fn sandbox_builder_wasm_file_not_found() {
3407        let result = Sandbox::builder()
3408            .with_wasm_file("/nonexistent/path/to/runtime.wasm")
3409            .with_python_stdlib("/fake/stdlib")
3410            .build();
3411
3412        assert!(result.is_err());
3413        let err = result.unwrap_err().to_string();
3414        assert!(
3415            err.contains("No such file")
3416                || err.contains("not found")
3417                || err.contains("failed to read"),
3418            "Expected file not found error, got: {err}"
3419        );
3420    }
3421
3422    #[test]
3423    fn sandbox_builder_invalid_wasm_bytes() {
3424        let result = Sandbox::builder()
3425            .with_wasm_bytes(vec![0, 1, 2, 3]) // Invalid WASM magic bytes
3426            .with_python_stdlib("/fake/stdlib")
3427            .build();
3428
3429        assert!(result.is_err());
3430        let err = result.unwrap_err().to_string();
3431        // wasmtime returns various errors for invalid WASM
3432        assert!(
3433            err.contains("magic") || err.contains("invalid") || err.contains("failed"),
3434            "Expected WASM parsing error, got: {err}"
3435        );
3436    }
3437
3438    #[test]
3439    fn sandbox_builder_empty_wasm_bytes() {
3440        let result = Sandbox::builder()
3441            .with_wasm_bytes(vec![])
3442            .with_python_stdlib("/fake/stdlib")
3443            .build();
3444
3445        assert!(result.is_err());
3446        let err = result.unwrap_err().to_string();
3447        assert!(
3448            err.contains("unexpected end")
3449                || err.contains("empty")
3450                || err.contains("failed")
3451                || err.contains("magic"),
3452            "Expected empty WASM error, got: {err}"
3453        );
3454    }
3455
3456    #[test]
3457    fn sandbox_builder_with_auto_stdlib_returns_result() {
3458        // with_auto_stdlib() returns a Result - it may succeed or fail depending
3459        // on whether a stdlib is found in standard locations.
3460        // We just verify the API works correctly.
3461        let result = Sandbox::builder()
3462            .with_wasm_bytes(vec![])
3463            .with_auto_stdlib();
3464
3465        // Either Ok (stdlib found) or Err (MissingPythonStdlib)
3466        match result {
3467            Ok(builder) => {
3468                // Stdlib was found, builder is now fully configured
3469                // Build will fail due to invalid WASM, but typestate is satisfied
3470                let build_result = builder.build();
3471                assert!(build_result.is_err()); // Invalid WASM
3472            }
3473            Err(e) => {
3474                // Stdlib not found - should be MissingPythonStdlib error
3475                let err_str = e.to_string();
3476                assert!(
3477                    err_str.contains("stdlib") || err_str.contains("Python"),
3478                    "Expected stdlib-related error, got: {err_str}"
3479                );
3480            }
3481        }
3482    }
3483
3484    #[test]
3485    #[cfg(not(feature = "embedded"))]
3486    fn sandbox_builder_requires_explicit_config_without_embedded() {
3487        // Without the embedded feature, Sandbox::builder() returns SandboxBuilder<Needs, Needs>
3488        // and build() is not available until both are configured.
3489        //
3490        // This is a compile-time check, but we verify the types work correctly:
3491        let builder = Sandbox::builder();
3492
3493        // Can add callbacks without configuring runtime/stdlib
3494        let builder = builder.with_callback(TestCallback);
3495
3496        // Must configure both to get build()
3497        let builder = builder
3498            .with_wasm_bytes(vec![1, 2, 3])
3499            .with_python_stdlib("/fake");
3500
3501        // Now build() is available (will fail due to invalid WASM, but that's expected)
3502        let result = builder.build();
3503        assert!(result.is_err()); // Invalid WASM
3504    }
3505}