When people say Rust is a “safe language”, they often mean memory safety. And while memory safety is a great start, it’s far from all it takes to build robust applications.
Memory safety is important but not sufficient for overall reliability.
In this article, I want to show you a few common gotchas in safe Rust that the compiler doesn’t detect and how to avoid them.
Even in safe Rust code, you still need to handle various risks and edge cases. You need to address aspects like input validation and making sure that your business logic is correct.
Here are just a few categories of bugs that Rust doesn’t protect you from:
- Type casting mistakes (e.g. overflows)
- Logic bugs
- Panics because of using
unwrap
orexpect
- Malicious or incorrect
build.rs
scripts in third-party crates - Incorrect unsafe code in third-party libraries
- Race conditions
Let’s look at ways to avoid some of the more common problems. The tips are roughly ordered by how likely you are to encounter them.
Click here to expand the table of contents.
Overflow errors can happen pretty easily:
If price
and quantity
are large enough, the result will overflow.
Rust will panic in debug mode, but in release mode, it will silently wrap around.
To avoid this, use checked arithmetic operations:
Static checks are not removed since they don’t affect the performance of generated code. So if the compiler is able to detect the problem at compile time, it will do so:
The error message will be:
error: this arithmetic operation will overflow
-/main.rs:4:13
|
4 | let z = x * y; | ^^^^^ attempt to compute `2_u8 * 128_u8`, which would overflow
|
= note: ` ` on by default
For all other cases, use checked_add
, checked_sub
, checked_mul
, and checked_div
, which return None
instead of wrapping around on underflow or overflow.
Rust carefully balances performance and safety. In scenarios where a performance hit is acceptable, memory safety takes precedence.
Integer overflows can lead to unexpected results, but they are not inherently unsafe. On top of that, overflow checks can be expensive, which is why Rust disables them in release mode.
However, you can re-enable them in case your application can trade the last 1% of performance for better overflow detection.
Put this into your Cargo.toml
:
[]
= true
This will enable overflow checks in release mode. As a consequence, the code will panic if an overflow occurs.
See the docs for more details.
While we’re on the topic of integer arithmetic, let’s talk about type conversions.
Casting values with as
is convenient but risky unless you know exactly what you are doing.
let x: i32 = 42;
let y: i8 = x as i8;
There are three main ways to convert between numeric types in Rust:
-
⚠️ Using the
as
keyword: This approach works for both lossless and lossy conversions. In cases where data loss might occur (like converting fromi64
toi32
), it will simply truncate the value. -
Using
From::from()
: This method only allows lossless conversions. For example, you can convert fromi32
toi64
since all 32-bit integers can fit within 64 bits. However, you cannot convert fromi64
toi32
using this method since it could potentially lose data. -
Using
TryFrom
: This method is similar toFrom::from()
but returns aResult
instead of panicking. This is useful when you want to handle potential data loss gracefully.
If in doubt, prefer From::from()
and TryFrom
over as
.
- use
From::from()
when you can guarantee no data loss. - use
TryFrom
when you need to handle potential data loss gracefully. - only use
as
when you’re comfortable with potential truncation or know the values will fit within the target type’s range and when performance is absolutely critical.
(Adapted from StackOverflow answer by delnan and additional context.)
The as
operator is not safe for narrowing conversions.
It will silently truncate the value, leading to unexpected results.
What is a narrowing conversion?
It’s when you convert a larger type to a smaller type, e.g. i32
to i8
.
For example, see how as
chops off the high bits from our value:
So, coming back to our first example above, instead of writing
let x: i32 = 42;
let y: i8 = x as i8;
use TryFrom
instead and handle the error gracefully:
let y = i8 try_from.ok_or?;
Bounded types make it easier to express invariants and avoid invalid states.
E.g. if you have a numeric type and 0 is never a correct value, use std::num::NonZeroUsize
instead.
You can also create your own bounded types:
;
Whenever I see the following, I get goosebumps 😨:
let arr = ;
let elem = arr;
That’s a common source of bugs. Unlike C, Rust does check array bounds and prevents a security vulnerability, but it still panics at runtime.
Instead, use the get
method:
let elem = arr.get;
It returns an Option
which you can now handle gracefully.
See this blog post for more info on the topic.
This issue is related to the previous one. Say you have a slice and you want to split it at a certain index.
let mid = 4;
let arr = ;
let = arr.split_at;
You might expect that this returns a tuple of slices where the first slice contains all elements and the second slice is empty.
Instead, the above code will panic because the mid index is out of bounds!
To handle that more gracefully, use split_at_checked
instead:
let arr = ;
match arr.split_at_checked
This returns an Option
which allows you to handle the error case.
(Rust Playground)
More info about split_at_checked
here.
It’s very tempting to use primitive types for everything. Especially Rust beginners fall into this trap.
However, do you really accept any string as a valid username? What if it’s empty? What if it contains emojis or special characters?
You can create a custom type for your domain instead:
;
The next point is closely related to the previous one.
Can you spot the bug in the following code?
The problem is that you can have ssl
set to true
but ssl_cert
set to None
.
That’s an invalid state! If you try to use the SSL connection, you can’t because there’s no certificate.
This issue can be detected at compile-time:
Use types to enforce valid states:
In comparison to the previous section, the bug was caused by an invalid combination of closely related fields. To prevent that, clearly map out all possible states and transitions between them. A simple way is to define an enum with optional metadata for each state.
If you’re curious to learn more, here is a more in-depth blog post on the topic.
It’s quite common to add a blanket Default
implementation to your types.
But that can lead to unforeseen issues.
For example, here’s a case where the port is set to 0 by default, which is not a valid port number.
Instead, consider if a default value makes sense for your type.
If you blindly derive Debug
for your types, you might expose sensitive data.
Instead, implement Debug
manually for types that contain sensitive information.
Instead, you could write:
;
This prints
User
For production code, use a crate like secrecy
.
However, it’s not black and white either:
If you implement Debug
manually, you might forget to update the implementation when your struct changes.
A common pattern is to destructure the struct in the Debug
implementation to catch such errors.
Instead of this:
How about destructuring the struct to catch changes?
Thanks to Wesley Moore (wezm) for the hint and to Simon Brüggen (m3t0r) for the example.
Don’t blindly derive Serialize
and Deserialize
– especially for sensitive data.
The values you read/write might not be what you expect!
When deserializing, the fields might be empty. Empty credentials could potentially pass validation checks if not properly handled
On top of that, the serialization behavior could also leak sensitive data.
By default, Serialize
will include the password field in the serialized output, which could expose sensitive credentials in logs, API responses, or debug output.
A common fix is to implement your own custom serialization and deserialization methods by using impl<'de> Deserialize<'de> for UserCredentials
.
The advantage is that you have full control over input validation. However, the disadvantage is that you need to implement all the logic yourself.
An alternative strategy is to use the #[serde(try_from = "FromType")]
attribute.
Let’s take the Password
field as an example.
Start by using the newtype pattern to wrap the standard types and add custom validation:
;
Now implement TryFrom
for Password
:
With this trick, you can no longer deserialize invalid passwords:
let password: Password = from_str.unwrap;
(Try it on the Rust Playground)
Credits go to EqualMa’s article on dev.to and to Alex Burka (durka) for the hint.
This is a more advanced topic, but it’s important to be aware of it. TOCTOU (time-of-check to time-of-use) is a class of software bugs caused by changes that happen between when you check a condition and when you use a resource.
The safer approach opens the directory first, ensuring we operate on what we checked:
Here’s why it’s safer: while we hold the handle, the directory can’t be replaced with a symlink. This way, the directory we’re working with is the same as the one we checked. Any attempt to replace it won’t affect us because the handle is already open.
You’d be forgiven if you overlooked this issue before.
In fact, even the Rust core team missed it in the standard library.
What you saw is a simplified version of an actual bug in the std::fs::remove_dir_all
function.
Read more about it in this blog post about CVE-2022-21658.
Timing attacks are a nifty way to extract information from your application. The idea is that the time it takes to compare two values can leak information about them. For example, the time it takes to compare two strings can reveal how many characters are correct. Therefore, for production code, be careful with regular equality checks when handling sensitive data like passwords.
use ;
Protect Against Denial-of-Service Attacks with Resource Limits. These happen when you accept unbounded input, e.g. a huge request body which might not fit into memory.
Instead, set explicit limits for your accepted payloads:
const MAX_REQUEST_SIZE: usize = 1024 * 1024;
If you use Path::join
to join a relative path with an absolute path, it will silently replace the relative path with the absolute path.
use Path;
This is because Path::join
will return the second path if it is absolute.
I was not the only one who was confused by this behavior. Here’s a thread on the topic, which also includes an answer by Johannes Dahlström:
The behavior is useful because a caller […] can choose whether it wants to use a relative or absolute path, and the callee can then simply absolutize it by adding its own prefix and the absolute path is unaffected which is probably what the caller wanted. The callee doesn’t have to separately check whether the path is absolute or not.
And yet, I still think it’s a footgun.
It’s easy to overlook this behavior when you use user-provided paths.
Perhaps join
should return a Result
instead?
In any case, be aware of this behavior.
So far, we’ve only covered issues with your own code. For production code, you also need to check your dependencies. Especially unsafe code would be a concern. This can be quite challenging, especially if you have a lot of dependencies.
cargo-geiger is a neat tool that checks your dependencies for unsafe code. It can help you identify potential security risks in your project.
This will give you a report of how many unsafe functions are in your dependencies. Based on this, you can decide if you want to keep a dependency or not.
Here is a set of clippy lints that can help you catch these issues at compile time. See for yourself in the Rust playground.
Here’s the gist:
cargo check
will not report any issues.cargo run
will panic or silently fail at runtime.cargo clippy
will catch all issues at compile time (!) 😎
use Path;
use Duration;
Phew, that was a lot of pitfalls! How many of them did you know about?
Even if Rust is a great language for writing safe, reliable code, developers still need to be disciplined to avoid bugs.
A lot of the common mistakes we saw have to do with Rust being a systems programming language: In computing systems, a lot of operations are performance critical and inherently unsafe. We are dealing with external systems outside of our control, such as the operating system, hardware, or the network. The goal is to build safe abstractions on top of an unsafe world.
Rust shares an FFI interface with C, which means that it can do anything C can do. So, while some operations that Rust allows are theoretically possible, they might lead to unexpected results.
But not all is lost! If you are aware of these pitfalls, you can avoid them, and with the above clippy lints, you can catch most of them at compile time.
That’s why testing, linting, and fuzzing are still important in Rust.
For maximum robustness, combine Rust’s safety guarantees with strict checks and strong verification methods.
I hope you found this article helpful! If you want to take your Rust code to the next level, consider a code review by an expert. I offer code reviews for Rust projects of all sizes. Get in touch to learn more.