Safety Comments Matter
Pavlo Myroniuk May 31, 2025 #rust #unsafeTL;DR: This article is highly inspired by this GitHub comment.
Safety Comments Matter
SAFETY
comments are always welcome when dealing with unsafe
code in Rust. The clippy even has a lint to enforce writing them: rust-lang.github.io/rust-clippy/#undocumented_unsafe_blocks:
You can find such comments in many crates and std. Example:
// SAFETY: `NonNull` is `transparent` over a `*const T`, and `*const T`
// and `*mut T` have the same layout, so transitively we can transmute
// our `NonNull` to a `*mut T` directly.
unsafe
Such comments help us to understand safety preconditions and invariants of the unsafe operation, and how they are upheld. Devs usually tend to treat them as one another boring thing in the development process. I kinda feel the same, but the benefits of properly written unsafe
blocks are almost immeasurable.
But what is "a properly written unsafe
block"? A properly written unsafe
block has the following characteristics:
- Has a single unsafe operation.
- Documented using
SAFETY:
comment. In turn, to write a properSAFETY:
comment, you must follow the following requirements (src of the quote below):
- Explain why the code is "sound", detailing the invariants and preconditions that are maintained.
- Avoid assumptions without justifications.
- Do not use phrases like "we assume this is safe because…" or "this should be safe".
- Provide concrete evidence or reasoning that justifies the safety of the operation.
- Explicitly reference type invariants and prior checks when safety relies on it.
// SAFETY: index is guaranteed to be within bounds due to the prior check. unsafe ;
- Address each safety requirement individually, using a bullet list.
// SAFETY: // - ptr is non-null and properly aligned. // - The memory region ptr points to is valid for reads of len elements. unsafe ;
- Document FFI boundaries clearly, but if the only safety requirements are standard (e.g.: non-dangling pointers), for consistency, use the following concise comment verbatim:
// SAFETY: FFI call with no outstanding preconditions. unsafe ;
- Do not document how the resulting value is safely used later.
- Really focus only on why the current unsafe operation is sound.
- Subsequent operations should be documented as appropriate in the code.
- It’s welcomed to document how and why the function is used, but this should not be part of the
SAFETY:
comment.
- Write a separate paragraph out of the safety comment, typically above the safety comment.
// Passing None to GetModuleHandleW requests the handle to the // current process main executable module // // SAFETY: FFI call with no outstanding preconditions. let h_module: HMODULE = unsafe ;
- To keep the comment concise, do not use phrases like "this is safe because…". Go straight to the point (e.g.: "pointer is guaranteed not null due to the prior check").
- For readability and clarity, keep the
unsafe
block as small as possible, and untangled from other safe code. If needed, use intermediate variables.// SAFETY: … let intermediate_variable = unsafe ; match intermediate_variable
- If a precondition must be upheld by the caller, mark the function as unsafe and document it with a
# Safety
section listing the invariants./// Returns the length in characters of the C-string. /// /// # Safety /// /// - `s` must be a valid, null-terminated C-string. unsafe
😮💨. I hope you didn't get tired at this point. It is true that writing a good SAFETY:
comment can be hard and exhausting. But it is worth it.
For me, the most important point in SAFETY:
comments is that writing them requires thinking about preconditions and invariants. Devs seldom think about invariants and what can go wrong. When explaining why the unsafe
operation is safe, you check the corresponding unsafe function preconditions and how to uphold them.
You can use AI to help you write SAFETY:
comments, but remember that it is your responsibility to check that the generated ones list all needed preconditions and invariants.
Do not neglect SAFETY:
comments.
Example
Talk is cheap. Show me the code.
So, let's show some code. Suppose we have the following unsafe
code:
unsafe
The function above is pretty simple. It does some job and saves the resulting buffers and attributes into the out parameters. Now let's make it better. I know we can.
/// Does some job.
///
/// # SAFETY:
///
/// - The `context` pointer must be a valid non-null pointer to the application context and obtained using the [init_context] function.
/// - The `len` and `attrs` pointers must be non-null.
/// - The `buf` pointer must be non-null. The entire memory range behind it must be contained within a single allocated object,
/// be properly initialized and aligned.
unsafe
Much better, right? Let's recall what has changed:
- Added function doc comment with
# Safety
requirements. - Split unsafe operations into many
unsafe
blocks containing exactly one unsafe operation. - Every
unsafe
block now has theSAFETY:
comment. - Moved safe operation out of the unsafe block (I'm talking about the
.copy_from_slice
method call).
Now the caller knows that it is not enough to just allocate the memory. The memory needs to be initialized. Now the caller knows that len
and attrs
pointers cannot be NULL. And more... It sounds obvious, but the unsafe code becomes very dangerous in the blink of an eye.
Put A Finger Down
Let's play the Put A Finger Down game. (This section is optional and written mostly for fun). The rule is simple: you put one finger down for every true statement about your unsafe Rust code.
If you put down zero fingers, my congratulations 🎊. You are probably aware of what you are doing, and you can sleep peacefully. If you put down all your fingers, then, please, reconsider your life choices 🙂
☑️ Rust 2018 edition or older.
☑️ unsafe
blocks without SAFETY
comments.
☑️ Many unsafe operations per one unsafe block.
☑️ Your unsafe code has never been run using Miri.
☑️ Raw pointer <-> int
cast is a usual thing.
The reader understands that these statements mean something bad for your unsafe code. Let's discuss each of them. I want to make sure that we are on the same page.
Rust 2018 edition or older
Why is the Rust edition important in our case?
(I already described it here.)
The 2024 edition brought many important features related to unsafe
code and FFI.
unsafe
I like it a lot. Because in huge unsafe
functions, it's not clear which operations are unsafe and which are not.
// Before Rust 2024 the following code was accepted:
// In Rust 2024 you need to add the unsafe attribute:
Starting with the 2024 Edition, it is now required to mark these attributes as unsafe. This one applies to export_name, link_section, and no_mangle. It is crucial because previously the app could crash even if it contained only safe code:
static mut X: i32 = 23;
unsafe
Merely taking such a reference in violation of Rust's mutability XOR aliasing requirement has always been instantaneous undefined behavior, even if the reference is never read from or written to.
Adding the unsafe keyword helps to emphasize that it is the responsibility of the author of the extern block to ensure that the signatures are correct.
It produces a warning in Rust 2021, but in Rust 2024 it results in error.
Now you understand why the 2024 edition is preferred in unsafe code 😃.
unsafe
blocks without SAFETY
comments
The whole first part of the article is about it. You should already understand the importance of such comments in the code.
Many unsafe operations per one unsafe block
It is partially covered by the 2024 edition, but still can violated:
// Compiles without any warnings.
unsafe
It is not recommended (by me and some other dudes 😉) to have multiple unsafe operations in one unsafe
block.
Because it becomes much harder to keep the code safe and follow all safety preconditions.
Every unsafe operation should have its own unsafe
block and SAFETY
comment above it (you can see an example in the first part of the article).
Miri
I already described all pros and cons of Miri here: https://tbt.qkation.com/posts/miri/. Please, read this article so I don't need to repeat myself.
TL;DR: if you have many unsafe operations, then consider using Miri. Miri is awesome and will help you to prevent a lot of bugs.
Pointer <-> integer cast
Oh, this one deserves a separate article, but I will try to fit it in a few sentences.
I'm still convinced that the Rust documentation is the best place to learn about unsafe. To your attention, quotes from the std::ptr
module level documentation:
Pointers are not simply an “integer” or “address”. ...pointers need to somehow be more than just their addresses: they must have provenance. Provenance can affect whether a program has undefined behavior:
- It is undefined behavior to access memory through a pointer that does not have provenance over that memory.
- It is undefined behavior to
offset
a pointer across a memory range that is not contained in the allocated object it is derived from, or tooffset_from
two pointers not derived from the same allocated object. Provenance is used to say what exactly “derived from” even means: the lineage of a pointer is traced back to the Original Pointer it descends from, and that identifies the relevant allocated object. In particular, it’s always UB to offset a pointer derived from something that is now deallocated, except if the offset is 0.
and:
From this discussion, it becomes very clear that a
usize
cannot accurately represent a pointer, and converting from a pointer to ausize
is generally an operation which only extracts the address. Converting this address back into pointer requires somehow answering the question: which provenance should the resulting pointer have?Rust provides two ways of dealing with this situation: Strict Provenance and Exposed Provenance.
There is a lot more to say. But I think you can read the Rust doc by yourself. My point is that you should never cast a pointer to an integer and vice versa. Or if you even dare to do it, then at least use an appropriate API: expose_provenance
and with_exposed_provenance
.
Conclusion
Pointers are not simply an "integer" or "address" 🙂.