mem_isolate/
lib.rs

1//! # `mem-isolate`: *Run unsafe code safely*
2//!
3//! It runs your function via a `fork()`, waits for the result, and returns it.
4//!
5//! This grants your code access to an exact copy of memory and state at the
6//! time just before the call, but guarantees that the function will not affect
7//! the parent process's memory footprint in any way.
8//!
9//! It forces functions to be *memory pure* (pure with respect to memory), even
10//! if they aren't.
11//!
12//! ```
13//! use mem_isolate::execute_in_isolated_process;
14//!
15//! // No heap, stack, or program memory out here...
16//! let result = mem_isolate::execute_in_isolated_process(|| {
17//!     // ...Can be affected by anything in here
18//!     Box::leak(Box::new(vec![42; 1024]));
19//! });
20//! ```
21//!
22//! To keep things simple, this crate exposes only two public interfaces:
23//!
24//! * [`execute_in_isolated_process`] - The function that executes your code in
25//!   an isolated process.
26//! * [`MemIsolateError`] - The error type that function returns ☝️
27//!
28//! For more code examples, see [`examples/`](https://github.com/brannondorsey/mem-isolate/tree/main/examples).
29//! [This one](https://github.com/brannondorsey/mem-isolate/blob/main/examples/error-handling-basic.rs)
30//! in particular shows how you should think about error handling.
31//!
32//! For more information, see the [README](https://github.com/brannondorsey/mem-isolate).
33//!
34//! ## Supported Platforms
35//!
36//! Because of its heavy use of POSIX system calls, this crate only
37//! supports Unix-like operating systems (e.g. Linux, macOS, BSD).
38//!
39//! Windows and wasm support are not planned at this time.
40//!
41//!
42//! ## Feature Flags
43//!
44//! The following crate [feature
45//! flags](https://doc.rust-lang.org/cargo/reference/manifest.html#the-features-section)
46//! are available:
47//!
48//! * `tracing`: Enable [tracing](https://docs.rs/tracing) instrumentation.
49//!   Instruments all high-level functions in [`lib.rs`](https://github.com/brannondorsey/mem-isolate/blob/main/src/lib.rs) and creates spans for
50//!   child and parent processes in [`execute_in_isolated_process`]. Events are
51//!   mostly `debug!` and `error!` level. See [`examples/tracing.rs`](https://github.com/brannondorsey/mem-isolate/blob/main/examples/tracing.rs)
52//!   for an example.
53//!
54//! By default, no additional features are enabled.
55//!
56#![warn(missing_docs)]
57#![warn(clippy::pedantic, clippy::unwrap_used)]
58#![warn(missing_debug_implementations)]
59
60#[cfg(not(any(target_family = "unix")))]
61compile_error!(
62    "Because of its heavy use of POSIX system calls, this crate only supports Unix-like operating systems (e.g. Linux, macOS, BSD)"
63);
64
65mod macros;
66use macros::{debug, error};
67#[cfg(feature = "tracing")]
68// Don't import event macros like debug, error, etc. directly to avoid conflicts
69// with our macros (see just above^)
70use tracing::{Level, instrument, span};
71
72use libc::{EINTR, c_int};
73use std::fmt::Debug;
74use std::fs::File;
75use std::io::{Read, Write};
76use std::os::unix::io::FromRawFd;
77
78#[cfg(test)]
79mod tests;
80
81mod c;
82use c::{
83    ForkReturn, PipeFds, SystemFunctions, WaitpidStatus, child_process_exited_on_its_own,
84    child_process_killed_by_signal,
85};
86
87pub mod errors;
88pub use errors::MemIsolateError;
89use errors::{
90    CallableDidNotExecuteError::{ChildPipeCloseFailed, ForkFailed, PipeCreationFailed},
91    CallableExecutedError::{ChildPipeWriteFailed, DeserializationFailed, SerializationFailed},
92    CallableStatusUnknownError::{
93        CallableProcessDiedDuringExecution, ChildProcessKilledBySignal, ParentPipeCloseFailed,
94        ParentPipeReadFailed, UnexpectedChildExitStatus, UnexpectedWaitpidReturnValue, WaitFailed,
95    },
96};
97
98use MemIsolateError::{CallableDidNotExecute, CallableExecuted, CallableStatusUnknown};
99
100// Re-export the serde traits our public API depends on
101pub use serde::{Serialize, de::DeserializeOwned};
102
103// Child process exit status codes
104const CHILD_EXIT_HAPPY: i32 = 0;
105const CHILD_EXIT_IF_READ_CLOSE_FAILED: i32 = 3;
106const CHILD_EXIT_IF_WRITE_FAILED: i32 = 4;
107
108#[cfg(feature = "tracing")]
109const HIGHEST_LEVEL: Level = Level::ERROR;
110
111/// Executes a user-supplied `callable` in a forked child process so that any
112/// memory changes during execution do not affect the parent. The child
113/// serializes its result (using bincode) and writes it through a pipe, which
114/// the parent reads and deserializes.
115///
116/// # Example
117///
118/// ```rust
119/// use mem_isolate::execute_in_isolated_process;
120///
121/// let leaky_fn = || {
122///     // Leak 1KiB of memory
123///     let data: Vec<u8> = Vec::with_capacity(1024);
124///     let data = Box::new(data);
125///     Box::leak(data);
126/// };
127///
128/// let _ = execute_in_isolated_process(leaky_fn);
129/// // However, the memory is not leaked in the parent process here
130/// ```
131///
132/// # Errors
133///
134/// Error handling is organized into three levels:
135///
136/// 1. The first level describes the effect of the error on the `callable` (e.g.
137///    did your callable function execute or not)
138/// 2. The second level describes what `mem-isolate` operation caused the error
139///    (e.g. did serialization fail)
140/// 3. The third level is the underlying OS error if it is available (e.g. an
141///    `io::Error`)
142///
143/// For most applications, you'll care only about the first level:
144///
145/// ```rust
146/// use mem_isolate::{execute_in_isolated_process, MemIsolateError};
147///
148/// // Function that might cause memory issues
149/// let result = execute_in_isolated_process(|| {
150///     // Some operation
151///     "Success!".to_string()
152/// });
153///
154/// match result {
155///     Ok(value) => println!("Callable succeeded: {}", value),
156///     Err(MemIsolateError::CallableDidNotExecute(_)) => {
157///         // Safe to retry, callable never executed
158///         println!("Callable did not execute, can safely retry");
159///     },
160///     Err(MemIsolateError::CallableExecuted(_)) => {
161///         // Do not retry unless idempotent
162///         println!("Callable executed but result couldn't be returned");
163///     },
164///     Err(MemIsolateError::CallableStatusUnknown(_)) => {
165///         // Retry only if idempotent
166///         println!("Unknown if callable executed, retry only if idempotent");
167///     }
168/// }
169/// ```
170///
171/// For a more detailed look at error handling, see the documentation in the
172/// [`errors`] module.
173///
174/// ## Important Note on Closures
175///
176/// When using closures that capture and mutate variables from their environment,
177/// these mutations **only occur in the isolated child process** and do not affect
178/// the parent process's memory. For example, it may seem surprising that the
179/// following code will leave the parent's `counter` variable unchanged:
180///
181/// ```rust
182/// use mem_isolate::execute_in_isolated_process;
183///
184/// let mut counter = 0;
185/// let result = execute_in_isolated_process(|| {
186///     counter += 1;  // This increment only happens in the child process
187///     counter        // Returns 1
188/// });
189/// assert_eq!(counter, 0);  // Parent's counter remains unchanged
190/// ```
191///
192/// This is the intended behavior as the function's purpose is to isolate all
193/// memory effects of the callable. However, this can be surprising, especially
194/// for [`FnMut`] or [`FnOnce`] closures.
195#[cfg_attr(feature = "tracing", instrument(skip(callable)))]
196pub fn execute_in_isolated_process<F, T>(callable: F) -> Result<T, MemIsolateError>
197where
198    F: FnOnce() -> T,
199    T: Serialize + DeserializeOwned,
200{
201    #[cfg(feature = "tracing")]
202    let parent_span = span!(HIGHEST_LEVEL, "parent").entered();
203
204    let sys = get_system_functions();
205    let PipeFds { read_fd, write_fd } = create_pipe(&sys)?;
206
207    match fork(&sys)? {
208        ForkReturn::Child => {
209            #[cfg(feature = "tracing")]
210            let _child_span = {
211                std::mem::drop(parent_span);
212                span!(HIGHEST_LEVEL, "child").entered()
213            };
214            // NOTE: Fallible actions in the child must either serialize
215            // and send their error over the pipe, or exit with a code
216            // that can be inerpreted by the parent.
217            // TODO: Consider removing the serializations and just
218            // using exit codes as the only way to communicate errors.
219            // TODO: Get rid of all of the .expect()s
220
221            let mut writer = unsafe { File::from_raw_fd(write_fd) };
222            close_read_end_of_pipe_in_child_or_exit(&sys, &mut writer, read_fd);
223
224            let result = execute_callable(callable);
225            let encoded = serialize_result_or_error_value(result);
226            write_and_flush_or_exit(&sys, &mut writer, &encoded);
227            exit_happy(&sys)
228        }
229        ForkReturn::Parent(child_pid) => {
230            close_write_end_of_pipe_in_parent(&sys, write_fd)?;
231
232            let waitpid_bespoke_status = wait_for_child(&sys, child_pid)?;
233            error_if_child_unhappy(waitpid_bespoke_status)?;
234
235            let buffer: Vec<u8> = read_all_of_child_result_pipe(read_fd)?;
236            deserialize_result(&buffer)
237        }
238    }
239}
240
241#[must_use]
242#[cfg_attr(feature = "tracing", instrument)]
243fn get_system_functions() -> impl SystemFunctions {
244    // Use the appropriate implementation based on build config
245    #[cfg(not(test))]
246    let sys = c::RealSystemFunctions;
247
248    #[cfg(test)]
249    let sys = if c::mock::is_mocking_enabled() {
250        // Use the mock from thread-local storage
251        c::mock::get_current_mock()
252    } else {
253        // Create a new fallback mock if no mock is active
254        c::mock::MockableSystemFunctions::with_fallback()
255    };
256
257    debug!("using {:?}", sys);
258    sys
259}
260
261#[cfg_attr(feature = "tracing", instrument)]
262fn create_pipe<S: SystemFunctions>(sys: &S) -> Result<PipeFds, MemIsolateError> {
263    let pipe_fds = match sys.pipe() {
264        Ok(pipe_fds) => pipe_fds,
265        Err(err) => {
266            let err = CallableDidNotExecute(PipeCreationFailed(err));
267            error!("error creating pipe, propagating {:?}", err);
268            return Err(err);
269        }
270    };
271    debug!("pipe created: {:?}", pipe_fds);
272    Ok(pipe_fds)
273}
274
275#[cfg_attr(feature = "tracing", instrument)]
276fn fork<S: SystemFunctions>(sys: &S) -> Result<ForkReturn, MemIsolateError> {
277    match sys.fork() {
278        Ok(result) => Ok(result),
279        Err(err) => {
280            let err = CallableDidNotExecute(ForkFailed(err));
281            error!("error forking, propagating {:?}", err);
282            Err(err)
283        }
284    }
285}
286
287#[cfg_attr(feature = "tracing", instrument(skip(callable)))]
288fn execute_callable<F, T>(callable: F) -> T
289where
290    F: FnOnce() -> T,
291{
292    debug!("starting execution of user-supplied callable");
293    #[allow(clippy::let_and_return)]
294    let result = {
295        #[cfg(feature = "tracing")]
296        let _span = span!(HIGHEST_LEVEL, "inside_callable").entered();
297        callable()
298    };
299    debug!("finished execution of user-supplied callable");
300    result
301}
302
303#[cfg_attr(feature = "tracing", instrument)]
304fn wait_for_child<S: SystemFunctions>(
305    sys: &S,
306    child_pid: c_int,
307) -> Result<WaitpidStatus, MemIsolateError> {
308    debug!("waiting for child process");
309    let waitpid_bespoke_status = loop {
310        match sys.waitpid(child_pid) {
311            Ok(status) => break status,
312            Err(wait_err) => {
313                if wait_err.raw_os_error() == Some(EINTR) {
314                    debug!("waitpid interrupted with EINTR, retrying");
315                    continue;
316                }
317                let err = CallableStatusUnknown(WaitFailed(wait_err));
318                error!("error waiting for child process, propagating {:?}", err);
319                return Err(err);
320            }
321        }
322    };
323
324    debug!(
325        "wait completed, received status: {:?}",
326        waitpid_bespoke_status
327    );
328    Ok(waitpid_bespoke_status)
329}
330
331#[cfg_attr(feature = "tracing", instrument)]
332fn error_if_child_unhappy(waitpid_bespoke_status: WaitpidStatus) -> Result<(), MemIsolateError> {
333    let result = if let Some(exit_status) = child_process_exited_on_its_own(waitpid_bespoke_status)
334    {
335        match exit_status {
336            CHILD_EXIT_HAPPY => Ok(()),
337            CHILD_EXIT_IF_READ_CLOSE_FAILED => {
338                Err(CallableDidNotExecute(ChildPipeCloseFailed(None)))
339            }
340            CHILD_EXIT_IF_WRITE_FAILED => Err(CallableExecuted(ChildPipeWriteFailed(None))),
341            unhandled_status => Err(CallableStatusUnknown(UnexpectedChildExitStatus(
342                unhandled_status,
343            ))),
344        }
345    } else if let Some(signal) = child_process_killed_by_signal(waitpid_bespoke_status) {
346        Err(CallableStatusUnknown(ChildProcessKilledBySignal(signal)))
347    } else {
348        Err(CallableStatusUnknown(UnexpectedWaitpidReturnValue(
349            waitpid_bespoke_status,
350        )))
351    };
352
353    if let Ok(()) = result {
354        debug!("child process exited happily on its own");
355    } else {
356        error!("child process signaled an error, propagating {:?}", result);
357    }
358
359    result
360}
361
362#[cfg_attr(feature = "tracing", instrument)]
363fn deserialize_result<T: DeserializeOwned>(buffer: &[u8]) -> Result<T, MemIsolateError> {
364    match bincode::deserialize::<Result<T, MemIsolateError>>(buffer) {
365        Ok(Ok(result)) => {
366            debug!("successfully deserialized happy result");
367            Ok(result)
368        }
369        Ok(Err(err)) => {
370            debug!("successfully deserialized error result: {:?}", err);
371            Err(err)
372        }
373        Err(err) => {
374            let err = CallableExecuted(DeserializationFailed(err.to_string()));
375            error!("failed to deserialize result, propagating {:?}", err);
376            Err(err)
377        }
378    }
379}
380
381/// Doesn't matter if the value is an error or not, we just want to serialize it either way
382///
383/// # Panics
384///
385/// Panics if the serialization of a [`MemIsolateError`] fails
386#[cfg_attr(feature = "tracing", instrument(skip(result)))]
387fn serialize_result_or_error_value<T: Serialize>(result: T) -> Vec<u8> {
388    match bincode::serialize(&Ok::<T, MemIsolateError>(result)) {
389        Ok(encoded) => {
390            debug!(
391                "serialization successful, resulted in {} bytes",
392                encoded.len()
393            );
394            encoded
395        }
396        Err(err) => {
397            let err = CallableExecuted(SerializationFailed(err.to_string()));
398            error!(
399                "serialization failed, now attempting to serialize error: {:?}",
400                err
401            );
402            #[allow(clippy::let_and_return)]
403            let encoded = bincode::serialize(&Err::<T, MemIsolateError>(err))
404                .expect("failed to serialize error");
405            debug!(
406                "serialization of error successful, resulting in {} bytes",
407                encoded.len()
408            );
409            encoded
410        }
411    }
412}
413
414#[cfg_attr(feature = "tracing", instrument)]
415fn write_and_flush_or_exit<S, W>(sys: &S, writer: &mut W, buffer: &[u8])
416where
417    S: SystemFunctions,
418    W: Write + Debug,
419{
420    let result = writer.write_all(buffer).and_then(|()| writer.flush());
421    #[allow(unused_variables)]
422    if let Err(err) = result {
423        error!("error writing to pipe: {:?}", err);
424        // If we can't write to the pipe, we can't communicate the error either
425        // so we rely on the parent correctly interpreting the exit code
426        let exit_code = CHILD_EXIT_IF_WRITE_FAILED;
427        debug!("exiting child process with exit code: {}", exit_code);
428        #[allow(clippy::used_underscore_items)]
429        sys._exit(exit_code);
430    } else {
431        debug!("wrote and flushed to pipe successfully");
432    }
433}
434
435fn exit_happy<S: SystemFunctions>(sys: &S) -> ! {
436    // NOTE: We don't wrap this in #[cfg_attr(feature = "tracing", instrument)]
437    // because doing so results in a compiler error because of the `!` return type
438    // No idea why its usage is fine without the cfg_addr...
439    #[cfg(feature = "tracing")]
440    let _span = {
441        const FN_NAME: &str = stringify!(exit_happy);
442        span!(HIGHEST_LEVEL, FN_NAME).entered()
443    };
444
445    let exit_code = CHILD_EXIT_HAPPY;
446    debug!("exiting child process with exit code: {}", exit_code);
447
448    #[allow(clippy::used_underscore_items)]
449    sys._exit(exit_code);
450}
451
452#[cfg_attr(feature = "tracing", instrument)]
453fn read_all_of_child_result_pipe(read_fd: c_int) -> Result<Vec<u8>, MemIsolateError> {
454    // Read from the pipe by wrapping the read fd as a File
455    let mut buffer = Vec::new();
456    {
457        let mut reader = unsafe { File::from_raw_fd(read_fd) };
458        if let Err(err) = reader.read_to_end(&mut buffer) {
459            let err = CallableStatusUnknown(ParentPipeReadFailed(err));
460            error!("error reading from pipe, propagating {:?}", err);
461            return Err(err);
462        }
463    } // The read_fd will automatically be closed when the File is dropped
464
465    if buffer.is_empty() {
466        // TODO: How can we more rigorously know this? Maybe we write to a mem map before and after execution?
467        let err = CallableStatusUnknown(CallableProcessDiedDuringExecution);
468        error!("buffer unexpectedly empty, propagating {:?}", err);
469        return Err(err);
470    }
471
472    debug!("successfully read {} bytes from pipe", buffer.len());
473    Ok(buffer)
474}
475
476#[cfg_attr(feature = "tracing", instrument)]
477fn close_write_end_of_pipe_in_parent<S: SystemFunctions>(
478    sys: &S,
479    write_fd: c_int,
480) -> Result<(), MemIsolateError> {
481    if let Err(err) = sys.close(write_fd) {
482        let err = CallableStatusUnknown(ParentPipeCloseFailed(err));
483        error!("error closing write end of pipe, propagating {:?}", err);
484        return Err(err);
485    }
486    debug!("write end of pipe closed successfully");
487    Ok(())
488}
489
490#[cfg_attr(feature = "tracing", instrument)]
491fn close_read_end_of_pipe_in_child_or_exit<S: SystemFunctions>(
492    sys: &S,
493    writer: &mut (impl Write + Debug),
494    read_fd: c_int,
495) {
496    if let Err(close_err) = sys.close(read_fd) {
497        let err = CallableDidNotExecute(ChildPipeCloseFailed(Some(close_err)));
498        error!(
499            "error closing read end of pipe, now attempting to serialize error: {:?}",
500            err
501        );
502
503        let encoded = bincode::serialize(&err).expect("failed to serialize error");
504        writer
505            .write_all(&encoded)
506            .expect("failed to write error to pipe");
507        writer.flush().expect("failed to flush error to pipe");
508
509        let exit_code = CHILD_EXIT_IF_READ_CLOSE_FAILED;
510        error!("exiting child process with exit code: {}", exit_code);
511        #[allow(clippy::used_underscore_items)]
512        sys._exit(exit_code);
513    } else {
514        debug!("read end of pipe closed successfully");
515    }
516}