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.
Why Rust Can’t Always Help
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.
Table of Contents
Click here to expand the table of contents.
- Protect Against Integer Overflow
- Avoid
as
For Numeric Conversions - Use Bounded Types for Numeric Values
- Don’t Index Into Arrays Without Bounds Checking
- Use
split_at_checked
Instead Ofsplit_at
- Make Invalid States Unrepresentable
- Avoid Primitive Types For Business Logic
- Handle Default Values Carefully
- Implement
Debug
Safely - Careful With Serialization
- Protect Against Time-of-Check to Time-of-Use (TOCTOU)
- Use Constant-Time Comparison for Sensitive Data
- Don’t Accept Unbounded Input
- Surprising Behavior of
Path::join
With Absolute Paths - Check For Unsafe Code In Your Dependencies With
cargo-geiger
- Conclusion
Protect Against Integer Overflow
Overflow errors can happen pretty easily:
// DON'T: Use unchecked arithmetic
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:
// DO: 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; // Compile-time error!
| ^^^^^ 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. 1
Quick Tip: Enable Overflow Checks In Release Mode
Rust carefully balances performance and safety. In scenarios where a performance hit is acceptable, memory safety takes precedence. 1
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. 2
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 # Enable integer overflow checks in release mode
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.
Avoid as
For Numeric Conversions
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; // Can overflow!
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.
Quick Tip: Safe Numeric Conversions
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; // Can overflow!
use TryFrom
instead and handle the error gracefully:
let y = i8 try_from.ok_or?;
Use Bounded Types for Numeric Values
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:
// DON'T: Use raw numeric types for domain values
// DO: Create bounded types
;
Don’t Index Into Arrays Without Bounds Checking
Whenever I see the following, I get goosebumps 😨:
let arr = ;
let elem = arr; // Panic!
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.
Use split_at_checked
Instead Of split_at
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 = ;
// This returns an Option
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.
Avoid Primitive Types For Business Logic
It’s very tempting to use primitive types for everything. Especially Rust beginners fall into this trap.
// DON'T: Use primitive types for usernames
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:
;
Make Invalid States Unrepresentable
The next point is closely related to the previous one.
Can you spot the bug in the following code?
// DON'T: Allow invalid combinations
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:
// First, let's define the possible states for the connection
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.
Handle Default Values Carefully
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.2
// DON'T: Implement `Default` without consideration
// Might create invalid states!
Instead, consider if a default value makes sense for your type.
// DO: Make Default meaningful or don't implement it
Implement Debug
Safely
If you blindly derive Debug
for your types, you might expose sensitive data.
Instead, implement Debug
manually for types that contain sensitive information.
// DON'T: Expose sensitive data in debug output
Instead, you could write:
// DO: Implement Debug manually
;
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:
// don't
How about destructuring the struct to catch changes?
// do
Thanks to Wesley Moore (wezm) for the hint and to Simon Brüggen (m3t0r) for the example.
Careful With Serialization
Don’t blindly derive Serialize
and Deserialize
– especially for sensitive data.
The values you read/write might not be what you expect!
// DON'T: Blindly derive Serialize and Deserialize
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:
// Tell serde to call `Password::try_from` with a `String`
;
Now implement TryFrom
for Password
:
With this trick, you can no longer deserialize invalid passwords:
// Panic: password too short!
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.
Protect Against Time-of-Check to Time-of-Use (TOCTOU)
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.
// DON'T: Vulnerable approach with separate check and use
The safer approach opens the directory first, ensuring we operate on what we checked:
// DO: Safer approach that opens first, then checks
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.
Use Constant-Time Comparison for Sensitive Data
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.
// DON'T: Use regular equality for sensitive comparisons
// DO: Use constant-time comparison
use ;
Don’t Accept Unbounded Input
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.
// DON'T: Accept unbounded input
Instead, set explicit limits for your accepted payloads:
const MAX_REQUEST_SIZE: usize = 1024 * 1024; // 1MiB
Surprising Behavior of Path::join
With Absolute Paths
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.
Check For Unsafe Code In Your Dependencies With cargo-geiger
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.
Clippy Can Prevent Many Of These Issues
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 (!) 😎
// Arithmetic
// Prevent operations that would cause integer overflow
// Suggest using checked conversions between numeric types
// Detect when casting might truncate a value
// Detect when casting might lose sign information
// Detect when casting might cause value to wrap around
// Detect when casting might lose precision
// Highlight potential bugs from integer division truncation
// Detect arithmetic operations with potential side effects
// Ensure duration subtraction won't cause underflow
// Unwraps
// Discourage using .unwrap() which can cause panics
// Discourage using .expect() which can cause panics
// Prevent unwrap on values known to cause panics
// Prevent unwrapping environment variables which might be absent
// Array indexing
// Avoid direct array indexing and use safer methods like .get()
// Path handling
// Prevent issues when joining paths with absolute paths
// Serialization issues
// Prevent incorrect usage of Serde's serialization/deserialization API
// Unbounded input
// Prevent creating uninitialized vectors which is unsafe
// Unsafe code detection
// Prevent unsafe transmutation from integers to characters
// Prevent unsafe transmutation from integers to floats
// Prevent unsafe transmutation from pointers to references
// Detect transmutes with potentially undefined representations
use Path;
use Duration;
Conclusion
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.
Let an Expert Review Your Rust Code
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.