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 necessary 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
unwraporexpect - Malicious or incorrect
build.rsscripts 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
asFor Numeric Conversions - Use Bounded Types for Numeric Values
- Don’t Index Into Arrays Without Bounds Checking
- Use
split_at_checkedInstead Ofsplit_at - Make Invalid States Unrepresentable
- Avoid Primitive Types For Business Logic
- Handle Default Values Carefully
- Implement
DebugSafely - 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::joinWith 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.
-
One example where Rust accepts a performance cost for safety would be checked array indexing, which prevents buffer overflows at runtime. Another is when the Rust maintainers fixed float casting because the previous implementation could cause undefined behavior when casting certain floating point values to integers. ↩
-
According to some benchmarks, overflow checks cost a few percent of performance on typical integer-heavy workloads. See Dan Luu’s analysis here ↩
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
askeyword: This approach works for both lossless and lossy conversions. In cases where data loss might occur (like converting fromi64toi32), it will simply truncate the value. -
Using
From::from(): This method only allows lossless conversions. For example, you can convert fromi32toi64since all 32-bit integers can fit within 64 bits. However, you cannot convert fromi64toi32using this method since it could potentially lose data. -
Using
TryFrom: This method is similar toFrom::from()but returns aResultinstead 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
TryFromwhen you need to handle potential data loss gracefully. - only use
aswhen 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 = i8try_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; // uh-oh!
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? That would likely be unexpected.
Instead, you can create a custom type for your domain:
;
Make Invalid States Unrepresentable
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 by using types to enforce valid states. First, let’s define the possible states for the connection:
Now we can’t have an invalid state! Either we have an SSL connection with a certificate or we don’t have SSL at all.
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.
The learning here is that Rust can’t protect you from logic bugs. 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 without thinking twice about it.
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.
If there is no sane default, don’t implement Default at all and let the user be explicit.
// DO: Make Default meaningful or don't implement it
Implement Debug Safely
A related issue is the Debug trait.
One might assume that Debug is only used for “debugging purposes” and is therefore harmless, but if you blindly derive Debug for all types, you might expose sensitive data.
That’s because Debug is often used in logging and error messages, even in production code.
Instead, implement Debug manually for types that contain sensitive information.
// This would expose sensitive data in logs!
Instead, you could write:
;
// Here we implement Debug manually to redact the password
Let’s say we were to print a User instance:
let user = User ;
println!;
The output would be:
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 either, 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:
// 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 checkwill not report any issues.cargo runwill panic or silently fail at runtime.cargo clippywill 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.
-
There’s also methods for wrapping and saturating arithmetic, which might be useful in some cases. It’s worth it to check out the
std::intrinsicsdocumentation to learn more. ↩ -
Port 0 usually means that the OS will assign a random port for you. So,
TcpListener::bind("127.0.0.1:0").unwrap()is valid, but it might not be supported on all operating systems or it might not be what you expect. See theTcpListener::binddocs for more info. ↩