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}