Safely Wrapping Crash-Prone C Code in Rust Without Full Migration

:magnifying_glass_tilted_left: Context

We're integrating legacy C code into a Rust application via FFI. The C codebase is extensive and cannot be modified or fully migrated to Rust at this time. Some C functions exhibit undefined behavior (e.g., division by zero, null pointer dereferences), leading to crashes that propagate into the Rust application.

:white_check_mark: Requirements

  • No process forking: Avoiding the overhead of spawning separate processes for isolation.
  • Memory safety: Prevent issues like dangling pointers and buffer overflows from affecting Rust code.
  • Undefined behavior handling: Gracefully handle errors such as division by zero and null dereferences originating from C code.
  • Thread safety: Ensure safe concurrent calls to C functions.
  • Rust-style error handling: Convert C failures into Result types in Rust.
  • Performance: Prefer zero-cost abstractions where possible.

:prohibited: Constraints

  • Immutable C code: Cannot modify the existing C source code.
  • Partial Rust migration: The team is not ready for a full migration to Rust.
  • Maintain existing FFI interface: Need to preserve the current interface between Rust and C.

:test_tube: Attempted Solutions

  • catch_unwind: Ineffective for catching undefined behavior like segmentation faults.
  • Process isolation: Too resource-intensive for our use case.
  • Manual checks: Insufficient for covering all cases of undefined behavior.

:puzzle_piece: Objective

Seeking a Rust-only solution to safely wrap and handle errors from crash-prone C code without modifying the C source or incurring significant performance penalties. The solution should uniquely leverage Rust's capabilities to provide safety guarantees that are difficult to achieve in C or C++.

use std::ffi::{CStr, CString};
use std::os::raw::c_char;

#[link(name = "example_static", kind = "static")]
extern "C" {
    fn errore(a: i32, b: i32) -> i32;
}

fn main() {
    unsafe {
        let result = errore(100, 0); // Potential division by zero error but I want to handle without c midfication
        println!("Result: {}", result);
    }
}

Challenge:
This is an open challenge to find a solution not in C or C++, and without the overhead of boilerplate code! If you're truly experienced with Rust, show it here.

I'm trying to convince my team to migrate to Rust—not just for safety, but also because I can handle undefined behaviors from old C and C++ modules within Rust. If the same is easily achievable in C or C++, they won't be convinced to switch.

So, the answer must be unique to Rust. I'm new to Rust and currently working on a proof of concept (PoC).

This is the only reliable way, really. The OS provides isolation; you can't run native C code in your process and get isolation. Once you're on a path leading to UB it's too late to do anything about it.

Rust's safety guarantees can't help unmodified C code. They're not magic.


The only in-process way would be to compile the C to wasm, and run it in a sandbox that treats escapes as a security issue (such as wasmtime).

11 Likes

I agree that process isolation is the only way to prevent UB in the C/C++ code from infecting the entire application, since that's the nature of UB. But even with process isolation, there would still be a negative impact, just as there would be if you were to catch a panic -- recovery from the panic isn't always possible since an incomplete operation may not be "fixable". Isolating the responsibilities of the C/C++ code such that recovery is possible would be quite a challenge, and I'm sure it would require changes to the C/C++ code. Doing this is similar to implementing a database transaction, where when a failure occurs, you can roll back the partial changes and retry the operation.

If you're currently running the C/C++ code and experiencing crashes, wouldn't it be reasonable to expect to continue to experience those crashes until the time that you can rewrite those parts of the code in Rust? In other words, why wouldn't it be acceptable to make incremental improvements?

1 Like

Yes, the old modules written in C/C++ may have undefined behavior (UB), but that's exactly why migrating to Rust makes sense. If I can handle those undefined behaviors safely in Rust—by isolating them using Rust’s FFI, process boundaries, or custom wrappers—that becomes a strong justification for migration.

Rust gives me the tools to contain and control the damage caused by legacy UB, and that’s something not easily achievable in C/C++ itself. On top of that, all new modules written in Rust are inherently memory-safe and free from UB by design. So with Rust, I get both: safety around old code and guaranteed safety in new code.

This dual advantage—handling old UB safely and preventing new UB entirely—is the best reason to migrate.

Rust doesn't do this. UB only has meaning during compilation, so by the time Rust encounters it, it's some fixed behavior. If you know the C code and compiler aren't going to change, you could possibly handle the fixed, buggy behavior, but Rust doesn't help here any more than C. But if that fixed behavior exits the process, like a segfault, then control never even returns to your Rust code and nothing can be done.

10 Likes

I need low-level access, but WebAssembly (Wasm) does not allow it.

Mozilla uses this in Firefox to sandbox C code.

It compiles C to WASM, and then translates WASM back to C. So you end up with C code that has WASM's run-time checks, and can only access a memory region given to it.

7 Likes

At the end of the day all your Rust and C code ends up a big blob of machine executable instructions. Your processor has no idea which language the instructions it is executing come from.

So, for example, if your C code has some UB that causes a pointer with invalid content to be used that could end up modifying memory used buy your Rust code and causing chaos. There is no way for Rust to prevent this.

You are going to have to put the C code into a separate process that will provide isolation. Or compile the C code to WASM and run it in WASM sand box from Rust.

FFI is the same process; it doesn't do anything to mitigate UB. Nor do custom wrappers. Inside the process is inside the process. You can't ask someone to make a bullet-proof vest but also insist that it can't weigh more than 10 grams.

So if you can't use process boundaries, it's time to start rubbing lamps, I guess.

I feel like the misunderstanding here is that Rust provides it's guarantees by some sort of runtime checking system; the entire point is that it doesn't!

Rust's safety is part of the language: this means specifically it doesn't accept source code that has UB in it (without the use of unsafe) - this happens before any output code is generated.

Because your C code doesn't go through Rust, you don't get it's benefits!

If you want checks at runtime, you need checks at runtime, that is, a sandbox. A process boundary is the best performing (and generally worst protecting) version of a sandbox; with shared memory and careful Rust code you can get all the performance/ access you like and all the safety you want in your Rust code - other options are generally progressively safer and slower.

You might be able to dig up the corpse of Google NaCl, which effectively directly ran machine code with sandboxing using the processor's memory protection, but it's been discontinued for years... in favor of WASM.

In short: sorry, sounds like to get what you want you'll probably need to rewrite the broken code in question.

3 Likes