In general: whenever you call into C, you get all the safety of C, because C code can trivially have memory corruption bugs that corrupt memory "belonging" to Rust as well, inducing undefined behavior across your whole program [0].
In addition, it's often considered permissible for C APIs to exhibit undefined behavior if their API contracts are violated. This means that a bug in Rust code that calls into a C API incorrectly can (indirectly) cause undefined behavior. For this reason, all FFI calls are marked unsafe in Rust.
The typical approach to using C libraries from Rust is to create a "safe wrapper" around the unsafe FFI calls that uses Rust's type system and lifetimes to enforce the safety invariants. Of course it's possible to mess up this wrapper and have accidental undefined behavior, but you're much less likely to do so through a safe wrapper than if you use the unsafe FFI calls directly (or use C or Zig) for a couple reasons:
- Writing the safe wrapper forces you to sit down and think about safety invariants instead of glossing over it.
- Once you're done, the compiler will check the safety invariants for you every time you use the API -- no chance of making a mistake or forgetting to read a comment.
[0]: This could be avoided/mitigated with some kind of lightweight in-process sandboxing (e.g. Intel MPK + seccomp) to prevent C libraries from accessing memory that they don't own or performing syscalls they shouldn't. There's some academic research on this (and I experimented with it myself for a masters thesis project), but it generally requires some (minimal) performance overhead and code changes at language boundaries.
> The typical approach to using C libraries from Rust is to create a "safe wrapper" around the unsafe FFI calls that uses Rust's type system and lifetimes to enforce the safety invariants.
Calling it a "safe" wrapper, when that safety is entirely dependent on (a) the correctness of the hand-written wrapper, (b) the safety of the underlying FFI code, has always been a huge stretch of terminology. It's more like a veil of safety, so we can shield our modest eyes from impure code that flaunts its unsafeness.
Rust has no magical ways of turning unsafe code safe, nor is it in any way special in being able to create statically-verifiable abstractions around an unsafe core.
Don't get me wrong, Rust is memory safe when used as a cohesive system, and I would encourage its use as such where memory safety is desired. But the idea of "safely" wrapping unsafe FFIs reminds me of the idea of packaging underwater mortgages into mortgage-backed securities and selling them as a safe investment.
> Rust has no magical ways of turning unsafe code safe
Fully agreed on that -- the unsafe keyword means it's unsafe.
> nor is it in any way special in being able to create statically-verifiable abstractions around an unsafe core.
This isn't really true. Rust's type system has:
- Affine types
- Lifetimes
- Borrowed and exclusive references
- Thread-safety
- Explicit delineation between safe and unsafe code -- with no UB in safe code, and a (cultural) design principle that unsafe code is not allowed to make unchecked assumptions about the behavior of safe code
Most mainstream languages do not have any one of these features, let alone all of them. Thus, Rust's ability to "create statically-verifiable abstractions around an unsafe core" is in practice far more powerful than in C, C++, or Zig. You certainly have the ability to create some such abstractions in other languages, but you cannot statically verify nearly as many properties. (And of course there are languages like SPARK that can verify even more statically than Rust.)
In my experience, as a systems programmer who heavily uses Rust to interact with FFI, hardware MMIO and DMA, interrupt handling, networking code, etc., Rust's ability to safely abstract unsafe primitives is easily the most practically useful aspect of the language. It's not a silver bullet that turns incorrect code into correct code, but it is incredibly good at verifying the correctness of a large application built from small low-level primitives. If you're interfacing with buggy C code, Rust certainly isn't going to help you much -- but it does makes a huge difference in preventing "you're holding it wrong" bugs when interacting with a C API.
An unsafe block declares an axiom: you're asserting to the compiler that you've verified (statically with the type system or dynamically with runtime assertions) all preconditions necessary for some primitive to be sound (free of UB). The compiler can then use that axiom to prove the soundness of all code that interacts with that primitive. When I write a driver to perform a DMA transfer, I only have to think through all the concurrency, alignment, lifetime, moveability, caching, and cancellation requirements once, and the compiler will check them for me every time I (or anyone else) use that driver in an application.
In addition, it's often considered permissible for C APIs to exhibit undefined behavior if their API contracts are violated. This means that a bug in Rust code that calls into a C API incorrectly can (indirectly) cause undefined behavior. For this reason, all FFI calls are marked unsafe in Rust.
The typical approach to using C libraries from Rust is to create a "safe wrapper" around the unsafe FFI calls that uses Rust's type system and lifetimes to enforce the safety invariants. Of course it's possible to mess up this wrapper and have accidental undefined behavior, but you're much less likely to do so through a safe wrapper than if you use the unsafe FFI calls directly (or use C or Zig) for a couple reasons:
- Writing the safe wrapper forces you to sit down and think about safety invariants instead of glossing over it.
- Once you're done, the compiler will check the safety invariants for you every time you use the API -- no chance of making a mistake or forgetting to read a comment.
[0]: This could be avoided/mitigated with some kind of lightweight in-process sandboxing (e.g. Intel MPK + seccomp) to prevent C libraries from accessing memory that they don't own or performing syscalls they shouldn't. There's some academic research on this (and I experimented with it myself for a masters thesis project), but it generally requires some (minimal) performance overhead and code changes at language boundaries.