Safety and Soundness in Rust
2023 March 5
Rust is designed around safety and soundness. Roughly speaking, safe code is
code that doesn't use the unsafe
keyword,​What we mean by "safe" depends on context, which is partly
what this post is about. Sometimes we even talk about safety and soundness
interchangeably, but here I want to emphasize the differences between them. and sound code is
code that can't cause memory corruption or other undefined
behavior.​"Undefined behavior" (UB) has a specific
meaning in languages like
C, C++, and Rust, which is different from "unspecified" or
"implementation-defined" behavior. One of Rust's most important features is the
promise that all safe code is sound. But that promise can be broken when
unsafe
code is involved, and unsafe
code is almost always involved
somewhere. Data structures like Vec
and HashMap
have unsafe
code in their
implementations, as does any function like File::open
that talks to the OS.
This leads to a common question: "If Rust can't guarantee that all safe code
is sound, how can it be a memory-safe language?" It's hard to give a short
answer to that question, so this post is my attempt at a medium-length answer.
The short answer
This version is dense and technical. You might want to take a quick look at it, move on to the next section, and then come back for another look at the end.
Rust has a list of behaviors considered undefined.​Rust doesn't yet have a formal specification, but there's general agreement that it needs one, and there's at least one serious ongoing effort to write one. Shortcuts like "do what C does" are complicated by known gaps in the C specification in areas like "pointer provenance". There are ongoing experiments around how to close those gaps, and the Miri project is also trying to make sure that the formal rules for UB will be programmatically checkable. A "sound" function is one that maintains the following invariant: any program that only calls sound functions and doesn't contain any other
unsafe
code, can't commit UB.​This definition is self-referential; the soundness of a function depends on what other functions are considered sound. It's possible to come up with two functions where either one could be sound, but not both at the same time. Niko Matsakis described how a hypothetical safe wrapper aroundsetjmp
/longjmp
could be sound in combination with "fundamental" Rust but unsound in combination with common (and now standard) threading libraries. There are a few other known examples of "soundness forks", but these issues are rare in application code. A function that doesn't use anyunsafe
code, either directly or indirectly, is guaranteed to be sound.​The Rust compiler has known bugs where it accepts some programs that should've failed to compile, and these bugs make it possible for 100% safe programs to commit UB. We call these bugs "soundness holes". It's rare for these to affect real-world code, though, and the minimized examples that trigger them are often pretty hard to understand. All the soundness holes we know of will get fixed eventually. Formally proving that Rust can fix all its soundness holes is a major research project and the sort of thing you might write your PhD thesis about. A function that doesn't use anyunsafe
code directly and only calls other sound functions, is also sound by definition. But functions and modules that useunsafe
code directly could be unsound, and a caller of an unsound function could also be unsound. Any unsoundness in the safe, public API of a module is a bug.​We usually evaluate soundness at module boundaries, because a safe write to a private field that otherunsafe
code depends on is often enough to commit UB. For example, any function in the implementation ofVec
could overwrite the privatelen
field and then do out-of-bounds reads and writes without using theunsafe
keyword directly.
The medium-length answer
Consider the following Rust function, foo1
, which reads a byte out of a
static string:​When the last line of a Rust function doesn't end in a
semicolon, that's an implicit return
.
static BYTES: &[u8] = b"hello world";
fn foo1(index: usize) -> u8 {
BYTES[index]
}
Here's a C version of foo1
:
const char* BYTES = "hello world";
char foo1(size_t index) {
if (index >= strlen(BYTES)) {
fprintf(stderr, "index out of bounds\n");
exit(1);
}
return BYTES[index];
}
Both versions of foo1
bounds-check the value of index
before they use it.
This check is automatic in the Rust version. Because of this check, we can't
make foo1
commit UB just by giving it a large index
. Instead, the only way
I can think of to make foo1
commit UB is to give it an uninitialized
index
. In C, we'd probably think of the resulting UB as
"the caller's fault". In Rust, using an uninitialized argument won't compile
in safe code, and doing it with unsafe
code is already UB in the
caller, before we even get to the body of foo1
. Since the
Rust version of foo1
will never cause UB without the caller writing unsafe
first,​This line originally read "without the caller committing UB first",
but Peter Ammon pointed out
that printing to stderr
can become UB after fclose(stderr)
or fork()
. foo1
is sound. Rust guarantees that functions like
foo1
, which don't use any unsafe
code either directly or indirectly, will
always be sound.
Now consider a slightly different function, foo2
, which doesn't do a bounds
check:
unsafe fn foo2(index: usize) -> u8 {
*BYTES.as_ptr().add(index)
}
Here's a C version of foo2
:
char foo2(size_t index) {
return *(BYTES + index);
}
Calling either version of foo2
with an index
that's too large will read
past the end of BYTES
, which is UB. Note that the Rust version of foo2
is
declared unsafe
in its signature, so calling it outside of another unsafe
function or unsafe
block is a compiler error. Since we can't call foo2
in
safe code, we don't usually ask whether it's sound or unsound; we just say that
it's "unsafe".​In theory there's nothing wrong with a function that's
both sound and unsafe
, but in practice it's odd. Why not allow safe code to
call the function, if it can't lead to UB? One answer could be that the
function is expected to become unsound in the future, so it's marked unsafe
now for compatibility. Dereferencing raw pointers like this isn't
allowed in safe Rust, so deleting the unsafe
keyword is also a compiler
error.
But if we move the unsafe
keyword down a bit, we start to get into trouble.
This function compiles:
fn foo3(index: usize) -> u8 {
unsafe {
*BYTES.as_ptr().add(index)
}
}
foo3
is like foo2
, except we've removed the unsafe
keyword from the
declaration and replaced it with an unsafe
block in the body. That means we
can call foo3
and commit UB from safe code. In other words,
foo3
is unsound.
We can get in deeper trouble by adding some indirection:
fn foo4(index: usize) -> u8 {
foo3(index)
}
foo4
is a thin wrapper around foo3
, so foo4
is also
unsound.
But foo4
doesn't contain any unsafe
code of its own. Instead, the
unsoundness of foo3
has "infected" foo4
. This sort of thing is why we can't
make a strong guarantee that all safe code is sound.
However, there's a slightly weaker guarantee that we can make. foo4
doesn't
contain any unsafe
code of its own, so it can't be unsound all by itself.
There must be some unsafe
code somewhere that's
responsible.​Apart from "soundness holes" in the compiler, it's also
possible for safe code to corrupt memory by asking the OS to do it in ways
the compiler doesn't know about. This includes tricks like writing to
/proc/$PID/mem
, or spawning a debugger and attaching it to yourself. If we
wanted to execute malicious safe code and still guarantee memory safety,
we'd need lots of help from the OS, and relying on process isolation instead
of memory safety would probably make more sense. In this case of course, it's foo3
that's
broken. There are two different ways we could fix foo3
: We could declare that
it's unsafe
in its signature like foo2
, which would make foo4
a compiler
error. Or we could make it do bounds checks like foo1
, which
would make foo4
sound with no changes. If we got rid of the unsafe
code in
foo3
, then one way or another Rust would make us do bounds checks.
So the simple promise of "no UB in safe code" can be broken. The slightly weaker guarantee above is harder to explain, but it's the more correct idea, and it's arguably Rust's most fundamental principle: A safe caller can't be "at fault" for memory corruption or other UB.
In this sense, wrapping unsafe
Rust in a safe API is like wrapping C
in a Python API, or in any other memory-safe language.​The Google Security Blog made a similar
comparison
between unsafe
Rust and JNI in Java.
Mistakes in Python aren't supposed to corrupt memory, and if they do,
we usually consider that a bug in the C bindings. Writing and reviewing
bindings isn't easy, but most applications contain little or no binding code of
their own. Similarly, most Rust applications contain little or no unsafe
code
of their own, and memory corruption is rare.
Discussion threads on r/rust, Hacker News, and lobste.rs.