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
4//! returns it.
5//!
6//! This grants your code access to an exact copy of memory and state at the
7//! time just before the call, but guarantees that the function will not affect
8//! the parent process's memory footprint in any way. It forces functions to be
9//! *pure*, even if they aren't.
10//!
11//! ```
12//! use mem_isolate::execute_in_isolated_process;
13//!
14//! // No heap, stack, or program memory out here...
15//! let result = mem_isolate::execute_in_isolated_process(|| {
16//! // ...Can be affected by anything in here
17//! Box::leak(Box::new(vec![42; 1024]));
18//! });
19//! ```
20//!
21//! To keep things simple, this crate exposes only two public interfaces:
22//!
23//! * [`execute_in_isolated_process`] - The function that executes your code in
24//! an isolated process.
25//! * [`MemIsolateError`] - The error type that function returns ☝️
26//!
27//! For more code examples, see [`examples/`](https://github.com/brannondorsey/mem-isolate/tree/main/examples).
28//! [This one](https://github.com/brannondorsey/mem-isolate/blob/main/examples/error-handling-basic.rs)
29//! in particular shows how you should think about error handling.
30//!
31//! For more information, see the [README](https://github.com/brannondorsey/mem-isolate).
32//!
33//! ## Supported platforms
34//!
35//! Because of its heavy use of POSIX system calls, this crate only
36//! supports Unix-like operating systems (e.g. Linux, macOS, BSD).
37//!
38//! Windows and wasm support are not planned at this time.
39#![warn(missing_docs)]
40#![warn(clippy::pedantic, clippy::unwrap_used)]
41#![warn(missing_debug_implementations)]
42
43#[cfg(not(any(target_family = "unix")))]
44compile_error!(
45 "Because of its heavy use of POSIX system calls, this crate only supports Unix-like operating systems (e.g. Linux, macOS, BSD)"
46);
47
48use std::fs::File;
49use std::io::{Read, Write};
50use std::os::unix::io::FromRawFd;
51
52#[cfg(test)]
53mod tests;
54
55mod c;
56use c::{
57 ForkReturn, PipeFds, SystemFunctions, child_process_exited_on_its_own,
58 child_process_killed_by_signal,
59};
60
61pub mod errors;
62pub use errors::MemIsolateError;
63use errors::{
64 CallableDidNotExecuteError::{ChildPipeCloseFailed, ForkFailed, PipeCreationFailed},
65 CallableExecutedError::{ChildPipeWriteFailed, DeserializationFailed, SerializationFailed},
66 CallableStatusUnknownError::{
67 CallableProcessDiedDuringExecution, ChildProcessKilledBySignal, ParentPipeCloseFailed,
68 ParentPipeReadFailed, UnexpectedChildExitStatus, UnexpectedWaitpidReturnValue, WaitFailed,
69 },
70};
71
72use MemIsolateError::{CallableDidNotExecute, CallableExecuted, CallableStatusUnknown};
73
74// Re-export the serde traits our public API depends on
75pub use serde::{Serialize, de::DeserializeOwned};
76
77/// Executes a user-supplied `callable` in a forked child process so that any
78/// memory changes during execution do not affect the parent. The child
79/// serializes its result (using bincode) and writes it through a pipe, which
80/// the parent reads and deserializes.
81///
82/// # Example
83///
84/// ```rust
85/// use mem_isolate::execute_in_isolated_process;
86///
87/// let leaky_fn = || {
88/// // Leak 1KiB of memory
89/// let data: Vec<u8> = Vec::with_capacity(1024);
90/// let data = Box::new(data);
91/// Box::leak(data);
92/// };
93///
94/// let _ = execute_in_isolated_process(leaky_fn);
95/// // However, the memory is not leaked in the parent process here
96/// ```
97///
98/// # Errors
99///
100/// Error handling is organized into three levels:
101///
102/// 1. The first level describes the effect of the error on the `callable` (e.g.
103/// did your callable function execute or not)
104/// 2. The second level describes what `mem-isolate` operation caused the error
105/// (e.g. did serialization fail)
106/// 3. The third level is the underlying OS error if it is available (e.g. an
107/// `io::Error`)
108///
109/// For most applications, you'll care only about the first level:
110///
111/// ```rust
112/// use mem_isolate::{execute_in_isolated_process, MemIsolateError};
113///
114/// // Function that might cause memory issues
115/// let result = execute_in_isolated_process(|| {
116/// // Some operation
117/// "Success!".to_string()
118/// });
119///
120/// match result {
121/// Ok(value) => println!("Callable succeeded: {}", value),
122/// Err(MemIsolateError::CallableDidNotExecute(_)) => {
123/// // Safe to retry, callable never executed
124/// println!("Callable did not execute, can safely retry");
125/// },
126/// Err(MemIsolateError::CallableExecuted(_)) => {
127/// // Do not retry unless idempotent
128/// println!("Callable executed but result couldn't be returned");
129/// },
130/// Err(MemIsolateError::CallableStatusUnknown(_)) => {
131/// // Retry only if idempotent
132/// println!("Unknown if callable executed, retry only if idempotent");
133/// }
134/// }
135/// ```
136///
137/// For a more detailed look at error handling, see the documentation in the
138/// [`errors`] module.
139///
140/// ## Important Note on Closures
141///
142/// When using closures that capture and mutate variables from their environment,
143/// these mutations **only occur in the isolated child process** and do not affect
144/// the parent process's memory. For example, it may seem surprising that the
145/// following code will leave the parent's `counter` variable unchanged:
146///
147/// ```rust
148/// use mem_isolate::execute_in_isolated_process;
149///
150/// let mut counter = 0;
151/// let result = execute_in_isolated_process(|| {
152/// counter += 1; // This increment only happens in the child process
153/// counter // Returns 1
154/// });
155/// assert_eq!(counter, 0); // Parent's counter remains unchanged
156/// ```
157///
158/// This is the intended behavior as the function's purpose is to isolate all
159/// memory effects of the callable. However, this can be surprising, especially
160/// for [`FnMut`] or [`FnOnce`] closures.
161#[allow(clippy::missing_panics_doc)] // We have to panic in the child process but clippy can't tell this is OK
162#[allow(clippy::too_many_lines)] // TODO: Break this up for readability
163pub fn execute_in_isolated_process<F, T>(callable: F) -> Result<T, MemIsolateError>
164where
165 F: FnOnce() -> T,
166 T: Serialize + DeserializeOwned,
167{
168 // Statuses 3-63 are normally fair game
169 const CHILD_EXIT_HAPPY: i32 = 0;
170 const CHILD_EXIT_IF_READ_CLOSE_FAILED: i32 = 3;
171 const CHILD_EXIT_IF_WRITE_FAILED: i32 = 4;
172
173 // Use the appropriate implementation based on build config
174 #[cfg(not(test))]
175 let sys = c::RealSystemFunctions;
176
177 #[cfg(test)]
178 let sys = if c::mock::is_mocking_enabled() {
179 // Use the mock from thread-local storage
180 c::mock::get_current_mock()
181 } else {
182 // Create a new fallback mock if no mock is active
183 c::mock::MockableSystemFunctions::with_fallback()
184 };
185
186 // Create a pipe.
187 let PipeFds { read_fd, write_fd } = match sys.pipe() {
188 Ok(pipe_fds) => pipe_fds,
189 Err(err) => {
190 return Err(CallableDidNotExecute(PipeCreationFailed(err)));
191 }
192 };
193
194 match sys.fork() {
195 Err(err) => Err(CallableDidNotExecute(ForkFailed(err))),
196 Ok(ForkReturn::Child) => {
197 // NOTE: We chose to panic in the child if we can't communicate an error back to the parent.
198 // The parent can then interpret this an an UnexpectedChildDeath.
199
200 // Droping the writer will close the write_fd, so we take it early and explicitly to
201 // prevent use after free with two unsafe calls scattered around the child code below.
202 let mut writer = unsafe { File::from_raw_fd(write_fd) };
203
204 // Close the read end of the pipe
205 if let Err(close_err) = sys.close(read_fd) {
206 let err = CallableDidNotExecute(ChildPipeCloseFailed(Some(close_err)));
207 let encoded = bincode::serialize(&err).expect("failed to serialize error");
208 writer
209 .write_all(&encoded)
210 .expect("failed to write error to pipe");
211 writer.flush().expect("failed to flush error to pipe");
212 #[allow(clippy::used_underscore_items)]
213 sys._exit(CHILD_EXIT_IF_READ_CLOSE_FAILED);
214 }
215
216 // Execute the callable and handle serialization
217 let result = callable();
218 let encoded = match bincode::serialize(&Ok::<T, MemIsolateError>(result)) {
219 Ok(encoded) => encoded,
220 Err(err) => {
221 let err = CallableExecuted(SerializationFailed(err.to_string()));
222 bincode::serialize(&Err::<T, MemIsolateError>(err))
223 .expect("failed to serialize error")
224 }
225 };
226
227 // Write the result to the pipe
228 let write_result = writer.write_all(&encoded).and_then(|()| writer.flush());
229
230 if let Err(_err) = write_result {
231 // If we can't write to the pipe, we can't communicate the error either
232 // Parent will detect this as an UnexpectedChildDeath
233 #[allow(clippy::used_underscore_items)]
234 sys._exit(CHILD_EXIT_IF_WRITE_FAILED);
235 }
236
237 // Exit immediately; use _exit to avoid running atexit()/on_exit() handlers
238 // and flushing stdio buffers, which are exact clones of the parent in the child process.
239 #[allow(clippy::used_underscore_items)]
240 sys._exit(CHILD_EXIT_HAPPY);
241 // The code after _exit is unreachable because _exit never returns
242 }
243 Ok(ForkReturn::Parent(child_pid)) => {
244 // Close the write end of the pipe
245 if let Err(close_err) = sys.close(write_fd) {
246 return Err(CallableStatusUnknown(ParentPipeCloseFailed(close_err)));
247 }
248
249 // Wait for the child process to exit
250 let waitpid_bespoke_status = match sys.waitpid(child_pid) {
251 Ok(status) => status,
252 Err(wait_err) => {
253 return Err(CallableStatusUnknown(WaitFailed(wait_err)));
254 }
255 };
256
257 if let Some(exit_status) = child_process_exited_on_its_own(waitpid_bespoke_status) {
258 match exit_status {
259 CHILD_EXIT_HAPPY => {}
260 CHILD_EXIT_IF_READ_CLOSE_FAILED => {
261 return Err(CallableDidNotExecute(ChildPipeCloseFailed(None)));
262 }
263 CHILD_EXIT_IF_WRITE_FAILED => {
264 return Err(CallableExecuted(ChildPipeWriteFailed(None)));
265 }
266 unhandled_status => {
267 return Err(CallableStatusUnknown(UnexpectedChildExitStatus(
268 unhandled_status,
269 )));
270 }
271 }
272 } else if let Some(signal) = child_process_killed_by_signal(waitpid_bespoke_status) {
273 return Err(CallableStatusUnknown(ChildProcessKilledBySignal(signal)));
274 } else {
275 return Err(CallableStatusUnknown(UnexpectedWaitpidReturnValue(
276 waitpid_bespoke_status,
277 )));
278 }
279
280 // Read from the pipe by wrapping the read fd as a File
281 let mut buffer = Vec::new();
282 {
283 let mut reader = unsafe { File::from_raw_fd(read_fd) };
284 if let Err(err) = reader.read_to_end(&mut buffer) {
285 return Err(CallableStatusUnknown(ParentPipeReadFailed(err)));
286 }
287 } // The read_fd will automatically be closed when the File is dropped
288
289 if buffer.is_empty() {
290 // TODO: How can we more rigorously know this? Maybe we write to a mem map before and after execution?
291 return Err(CallableStatusUnknown(CallableProcessDiedDuringExecution));
292 }
293 // Update the deserialization to handle child errors
294 match bincode::deserialize::<Result<T, MemIsolateError>>(&buffer) {
295 Ok(Ok(result)) => Ok(result),
296 Ok(Err(err)) => Err(err),
297 Err(err) => Err(CallableExecuted(DeserializationFailed(err.to_string()))),
298 }
299 }
300 }
301}