Skip to content(if available)orjump to list(if available)

Pitfalls of Safe Rust

Pitfalls of Safe Rust

143 comments

·April 4, 2025

nerdile

Title is slightly misleading but the content is good. It's the "Safe Rust" in the title that's weird to me. These apply to Rust altogether, you don't avoid them by writing unsafe Rust code. They also aren't unique to Rust.

A less baity title might be "Rust pitfalls: Runtime correctness beyond memory safety."

burakemir

It is consistent with the way the Rust community uses "safe": as "passes static checks and thus protects from many runtime errors."

This regularly drives C++ programmers mad: the statement "C++ is all unsafe" is taken as some kind of hyperbole, attack or dogma, while the intent may well be to factually point out the lack of statically checked guarantees.

It is subtle but not inconsistent that strong static checks ("safe Rust") may still leave the possibility of runtime errors. So there is a legitimate, useful broader notion of "safety" where Rust's static checking is not enough. That's a bit hard to express in a title - "correctness" is not bad, but maybe a bit too strong.

whytevuhuni

No, the Rust community almost universally understands "safe" as referring to memory safety, as per Rust's documentation, and especially the unsafe book, aka Rustonomicon [1]. In that regard, Safe Rust is safe, Unsafe Rust is unsafe, and C++ is also unsafe. I don't think anyone is saying "C++ is all unsafe."

You might be talking about "correct", and that's true, Rust generally favors correctness more than most other languages (e.g. Rust being obstinate about turning a byte array into a file path, because not all file paths are made of byte arrays, or e.g. the myriad string types to denote their semantics).

[1] https://doc.rust-lang.org/nomicon/meet-safe-and-unsafe.html

pjmlp

Mostly, there is a sub culture that promotes to taint everything as unsafe that could be used incorrectly, instead of memory safety related operations.

brundolf

Formally the team/docs are very clear, but I think many users of Rust miss that nuance and lump memory safety together with all the other features that create the "if it compiles it probably works" experience

So I agree with the above comment that the title could be better, but I also understand why the author gave it this title

ampere22

If a C++ developer decides to use purely containers and smart pointers when starting a new project, how are they going to develop unsafe code?

Containers like std::vector and smart pointers like std::unique_ptr seem to offer all of the same statically checked guarantees that Rust does.

I just do not see how Rust is a superior language compared to modern C++

bigstrat2003

The problem with the title is that the phrase "pitfalls of safe rust" implies that these pitfalls are unique to, or made worse by, safe rust. But they aren't. They are challenges in any programming language, which are no worse in rust than elsewhere.

It's like if I wrote an article "pitfalls of Kevlar vests" which talked about how they don't protect you from being shot in the head. It's technically correct, but misleading.

antonvs

> This regularly drives C++ programmers mad

I thought the C++ language did that.

felbane

It certainly used to, but tbh C++ since 17 has been pretty decent and continually improving.

That said, I still prefer to use it only where necessary.

quotemstr

Safe Rust code doesn't have accidental remote code execution. C++ often does. C++ people need to stop pretending that "safety" is some nebulous and ill-defined thing. Everyone, even C++ people, shows perfectly damn well what it means. C++ people are just miffed that Rust built it while they slept.

surajrmal

Accidental remote code execution isn't limited to just memory safety bugs. I'm a huge rust fan but it's not good to oversell things. It's okay to be humble.

yjftsjthsd-h

Research I've seen seems to say that 70-80% of vulnerabilities come from memory safety problems[0]. Eliminating those is of course a huge improvement, but is rust doing something to kill the other 20-30%? Or is there something about RCE that makes it the exclusive domain of memory safety problems?

[0] For some reason I'm having trouble finding primary sources, but it's at least referenced in ex. https://security.googleblog.com/2024/09/eliminating-memory-s...

NoTeslaThrow

If english had static checks this kind of runtime pedantry would be unnecessary. Sometimes it's nice to devote part of your brain to productivity rather than checking coherence.

hu3

[flagged]

IshKebab

For integer overflows and array out of bounds I'm quite optimistic about Flux

https://github.com/flux-rs/flux

I haven't actually used it but I do have experience of refinement types / liquid types (don't ask me about the nomenclature) and IMO they occupy a very nice space just before you get to "proper" formal verification and having to deal with loop invariants and all of that complexity.

0rzech

> refinement types / liquid types (don't ask me about the nomenclature)

There's a nice FOSDEM presentation "Understanding liquid types, contracts and formal verification with Ada/SPARK" by Fernando Oleo Blanco (Irvise): https://fosdem.org/2025/schedule/event/fosdem-2025-4879-unde.... One of the slides says:

  Liquid types!
  Logically Qualified Types, aka, Types with Logic! Aka dependent types, etc...

conaclos

I find it strange that the article doesn't talk about the alternative to checked arithmetic: explicit Wrapping [0] and Saturating [1] types, also provided as methods on numeric types (e.g. `usize::MAX.saturating_add(1)`).

Regarding `as` casting, I completely agree. I am trying to use safe `From::from` instead. However, this is a bit noisy: `usize::from(n)` vs `n as usize`.

[0] https://doc.rust-lang.org/std/num/struct.Wrapping.html [1] https://doc.rust-lang.org/std/num/struct.Saturating.html

kelnos

> I am trying to use safe `From::from` instead. However, this is a bit noisy: `usize::from(n)` vs `n as usize`.

If there's enough information in the surrounding code for type inference to do its thing, you can shorten it to `n.into()`.

conaclos

Another limitation I face is the impossibility of using `usize::from(n)` in `const` context.

mre

True, I should add the wrapping types. They are actually quite useful if you know that you have a fixed range of values and you can't go above the min/max. Like a volume dial that just would stay at "max" if you turn up the volume; it wouldn't wrap around.

zk4x

This is very nice article, objectively lists possible pitfalls. It's however not quite that simple. I am in favor of removing as, but then try_from needs to work with more types, for example try converting u64 into f32 without using as. It turns out to be very hard. TryInto does not work in single step.

Important aspect is performance. HPC code needs to be able to opt out of math checks.

Also Results and Options are very costly, as they introduce lot of branching. Panic is just faster. Hopefully one day rust will use something like IEX by default https://docs.rs/iex/latest/iex/ It has the same benefits as Results, but if error is returned in less than 15% of function calls, then IEX is much faster.

Btw. allocation failure in std returning Result anytime soon?

Sharlin

> Surprising Behavior of Path::join With Absolute Paths

> 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.

Oh, hey, that's me! I agree that it's a footgun, for what it's worth, and there should probably get a dedicated "absolutize" method for getting the "prefix this if relative, leave as is if already absolute" semantics.

(link to the thread: https://users.rust-lang.org/t/rationale-behind-replacing-pat...)

glandium

It's the same in at least Python, so it's not a Rust idiosyncratic behavior. The "absolutize" method you're asking for exists since 1.79. https://doc.rust-lang.org/stable/std/path/fn.absolute.html

Sharlin

Yeah, but I meant a method that would take a custom prefix, like join does now. (In the hypothetical situation where join had different semantics.)

bennettnate5

Here's an important one: never use `mem::size_of_val(&T)`. Rust as a language strongly steers you towards ignoring double (or even triple)-referenced types because they're implicitly auto-dereferenced in most places, but the moment you try to throw one of those into this API it returns the size of the referenced reference `&T` which is very much not the same as `T`. I've been burned by this before, particularly in unsafe contexts; I only use `size_of::<T>()` now.

woah

Is "as" an uneccesary footgun?

whytevuhuni

That was my first impression as well. So much of Rust’s language and standard library enforces correctness, that gaps start to feel way more visible.

“as” is a good example. Floats are pretty much the only reason PartialEq exists, so why can’t we have a guaranteed-not-NaN-nor-inf type in std and use that everywhere? Why not make wrapping integers a panic even in release mode? Why not have proper dependent types (e.g. to remove bound checks), and proper linear types (to enforce that object destructors always run)?

It’s easy to forget that Rust is not an ideal language, but rather a very pragmatic one, and sometimes correctness loses in favour of some other goals.

bombela

I have been following rust very closely since 2013.

As Rust is both evolving and spreading wide, we; the programmers, users of Rust; are also leveling up in how we approach correctness and design with it.

Maybe the next evolution will be something like Haskell but fast like Rust is fast like C without the pain of C++.

But it takes a while for the world to catch up, and for everybody to explore and find ways to work with or around the abstractions that helps with correctness.

It's a bit like the evolution from a pointer to some malloc memory, then the shared/unique pointer of C++, to the fully safe box/(a)rc of Rust.

It might be obvious today how much more efficient it is programming with those abstractions.

I see some similarities with functional programming that still seems so niche. Even though the enlighteneds swears by it. And now we actually seem to be slowly merging the best parts of functional and imperative together somehow.

So maybe we are actually evolving programming as a species. And Rust happens to be one of the best scaffold at this point in history.

Thank you for reading my essay.

pjmlp

There is hardly any evolution from pointer to malloc, C is one of the few systems languages, including those that predated it, where one needs math to allocate heap memory.

I do agree that the evolution is most likely a language that combines automatic resource management with affine/linear/effects/dependent/proofs.

Or AIs improve to the point to render all existing programming languages a thing from the past, replaced by regular natural languages and regular math.

thayne

There is some movement towards deprecating "as", and lints that will recommend using alternatives when possible, but there are a couple of cases, such as intentional truncation, where there isn't a stable alternative yet.

FreezyLemon

Regarding the not-NAN float type, there was actually a proposal for it which was shot down: https://github.com/rust-lang/libs-team/issues/238.

I don't remember every argument in there but it seemed that there are good reasons not to add it unlike a NonZero integer type which seems to have no real downsides.

adgjlsfhk1

The other option would be to change how floating point works. IEEE specifies operations, not names, so it would be totally valid to have <= on floats be a total order (using integer cpu instructions), and make a function called IEEEAreIdiotsWhoThinkThisIsFloatingPointLessThan which is the partial order that sucks.

wongarsu

For purposes of sorting, Rust does offer a non-IEEE order as f64::total_cmp. You can easily build a wrapper type that uses that for all comparisons, or use a crate that does it for you

https://doc.rust-lang.org/std/primitive.f64.html#method.tota...

null

[deleted]

int_19h

Some of these don't strike me as particularly pragmatic. E.g. are overflow checks really that expensive, given that it's a well-known footgun that is often exploitable? Sure, you don't want, say, 10% overhead in your number-crunching codec or whatever, but surely it's better to have those cases opt in for better perf as needed, as opposed to a default behavior that silently produces invalid results?

mustache_kimono

> Some of these don't strike me as particularly pragmatic. E.g. are overflow checks really that expensive

Did you read the article? Rust includes overflow checks in debug builds, and then about a dozen methods (checked_mul, checked_add, etc.) which explicitly provide for checks in release builds.

Pragmatism, for me, is this help when you need it approach.

TBF Rust forces certain choices on one in other instances, like SipHash as the default Hasher for HashMap. But again opting out, like opting in, isn't hard.

hansvm

> guaranteed-not-NaN-nor-inf

Nor negative zero

bombela

Compared to C/C++ "as" feels so much safe r. Now that Rust and we the programmers have evolved with it, I too feel that "as" for narrowing conversion is a small foot gun.

bigstrat2003

I'm struggling to see how you would implement narrowing conversion in a way that is harder for programmers to misuse when they aren't being mindful, while also being pleasant to use when you really do want to just drop higher bits. Like, you could conceivably have something like a "try_narrow" trait which wraps the truncated value inside an Err when it doesn't fit, and it would probably be harder to accidentally misuse, but that's also really cumbersome to use when you are trying to truncate things.

recursivecaveat

I don't really want narrowing conversion to be harder, I just want checked conversion to be at least nearly as convenient. `x as usize` vs `x.try_into().unwrap()` becomes `x tiu usize` or something even. I'm not picky. It's kindof funny that this is the exact mistake C++ made, where the safe version of every container operation is the verbose one: `vector[]` vs `vector.at()` or `*optional` vs `optional.value()`, which results in tons and tons of memory problems for code that has absolutely no performance need for unchecked operations.

woah

let foo: u8 = bar<u64>.truncate_to()?

bigstrat2003

I wouldn't say so. I quite like "as". It can have sharp edges but I think the language would be significantly worse off without it.

wongarsu

It's useful to have something that does the job of "as", but I dislike how the most dangerous tool for type conversions has the nicest syntax.

Most of the time I want the behavior of ".try_into().unwrap()" (with the compiler optimizing the checks away if it's always safe) or would even prefer a version that only works if the conversion is safe and lossless (something I can reason about right now, but want to ensure even after refactorings). The latter is really hard to achieve, and ".try_into.unwrap()" is 20 characters where "as" is 2. Not a big deal to type with autocomplete, but a lot of visual clutter.

zozbot234

The question is not whether the language should include such a facility, but whether 'as' should be the syntax for it. 'as' is better than the auto conversions of C but it's still extremely obscure. It would be better to have some kind of explicit operator marking this kind of possibly unintended modulo conversion. Rust will gain safe transmute operations in the near future so that will perhaps be a chance to revise this whole area as well.

eptcyka

When fitting larger types into smaller ones? Yes.

quotemstr

Some of this advice is wrongheaded. Consider array indexing: usually, an out of bounds access indicates a logic error and should fail fast to abort the problem so it doesn't go further off the rails. Encouraging people to use try-things everywhere just encourage them to paper over logic bugs and leads to less reliable software in the end. Every generation has to learn this lesson anew through pain.

hansvm

Try-things have the benefit of accurately representing the thing you're describing. Leave it to the caller to decide whether to panic or resize the data structure or whatever.

That's also not the only choice in the design space for correct array accesses. Instead of indices being raw integers, you can use tagged types (in Rust, probably using lifetimes as the mechanism if you had to piggy back on existing features, but that's an implementation detail) and generate safe, tagged indices which allow safe access without having to bounds check on access.

However you do it, the point is to not lie about what you're actually doing and invoke a panic-handler-something as a cludgy way of working around the language.

bombela

I think what you are saying is that there must be an informed decision betwen crashing the program vs returning an error. Instead of returning an error for everything that happens to be a logic error at a given level of abstraction.

thrance

I'd add memory leaks to the list. Sometimes you feel compelled to wrap your data in an Rc or Arc (reference counted pointers for those unfamiliar) to appease the borrow checker. With capture semantics of closures and futures and such it's quite easy to fall into a referential cycle, which won't be freed when dropped.

pornel

I don't think it's a common concern in Rust. It used to be a problem in Internet Explorer. It's a footgun in Swift, but Rust's exclusive ownership and immutability make cycles very difficult to create by accident.

If you wrap a Future in Arc, you won't be able to use it. Polling requires exclusive access, which Arc disables. Most combinators and spawn() require exclusive ownership of the bare Future type. This is verified at compile time.

Making a cycle with `Arc` is impossible unless two other criteria are met:

1. You have to have a recursive type. `Arc<Data>` can't be recursive unless `Data` already contains `Arc<Data>` inside it, or some abstract type that could contain `Arc<Data>` in it. Rust doesn't use dynamic types by default, and most data types can be easily shown to never allow such cycle.

It's difficult to make a cycle with a closure too, because you need to have an instance of the closure before you can create an Arc, but your closure can't capture the Arc before it's created. It's a catch-22 that needs extra tricks to work around, which is not something that you can just do by accident.

2. Even if a type can be recursive, it's still not enough, because the default immutability of Arc allows only trees. To make a cycle you need the recursive part of the type to also be in a wrapper type allowing interior mutability, so you can modify it later to form a cycle (or use `Arc::new_cycle` helper, which is an obvious red flag, but you still need to upgrade the reference to a strong one after construction).

It's common to have Arc-wrapped Mutex. It's possible to have recursive types, but having both together at the same time are less common, and then still you need to make a cycle yourself, and dodge all the ownership and borrow checking issues required to poll a future in such type.

nurettin

> price.checked_mul(quantity)

nope, I won't do that. I'd rather waste away at a bank using early 2000s oracle forms . Make it a compiler flag, detect it statically or gtfo.

forrestthewoods

> Overflow errors can happen pretty easily

No they can’t. Overflows aren’t a real problem. Do not add checked_mul to all your maths.

Thankfully Rust changed overflow behavior from “undefined” to “well defined twos-complement”.

LiamPowell

What makes you think this is the case?

Having done a bunch of formal verification I can say that overflows are probably the most common type of bug by far.

imtringued

Yeah, they're so common they've become a part of our culture when it comes to interacting with computers.

Arithmetic overflows have become the punchline of video game exploits.

Unsigned underflow is also one of the most dangerous types. You go from one of the smallest values to one of the biggest values.

forrestthewoods

Unsigned integers were largely a mistake. Use i64 and called it a day. (Rusts refusal to allow indexing with i64 or isize is a huge mistake.)

Don’t do arithmetic with u8 or probably even u16.

conradludgate

Overflow errors absolutely do happen. They're just no longer UB. It doesn't make them non-errors though. If your bank account balance overflowed, you'd be pretty upset.

bogeholm

On the other hand, there’s a solid use case for underflow.

int_19h

The vast majority of code that does arithmetic will not produce a correct result with two's complement. It is simply assuming that the values involved are small enough that it won't matter. Sometimes it is a correct assumption, but whenever it involves anything derived from inputs, it can go very wrong.

zozbot234

For any arithmetic expression that involves only + - * operators and equally-sized machine words, two's complement will actually yield a "correct" result. It's just that the given result might be indicating a different range than you expect.

Spivak

This is something that's always bugged me because, yes, this is a real problem that produces real bugs. But at the same time if you really care about this issue then every arithmetic operation is unsafe and there is never a time you should use them without overflow checks. Sometimes you can know something won't overflow but outside of some niche type systems you can't really prove it to the compiler to elide the check in a way that is safe against code modifications— i.e. if someone edits code that breaks the assumption we needed to know it won't overflow it will err.

But at the same time in real code in the real world you just do the maths, throw caution to the wind, and if it overflows and produces a bug you just fix it there. It's not worth the performance hit and your fellow developers will call you mad if you try to have a whole codebase with only checked maths.

int_19h

I think this is very much a cultural issue rather than a technical one. Just look at array bounds checking: widespread in the mainframe era even in systems languages, relegated to high-level languages for a very long time on the basis of unacceptable perf hit in low-level code, but more recently seeing more acceptance in new systems languages (e.g. Rust).

Similarly in this case, it's not like we don't have languages that do checked arithmetic throughout by default. VB.NET, for example, does exactly that. Higher-level languages have other strategies to deal with the problem; e.g. unbounded integer types as in Python, which simply never overflow. And, like you say, this sort of thing is considered unacceptable for low-level code on perf grounds, but, given the history with nulls and OOB checking, I think there is a lesson here.

wongarsu

I'm a big fan of liberal use of saturating_mul/add/sub whenever there is a conceivable risk of coming withing a couple orders of magnitude of overflow. Or checked_*() or whatever the best behavior in the given case is. For my code it happens to mostly be saturating.

Overflow bugs are a real pain, and so easy to prevent in Rust with just a function call. It's pretty high on my list of favorite improvements over C/C++

forrestthewoods

If you saturate you almost never ever want to use the result. You need to check and if it saturates do something else.

wongarsu

You obviously have to decide it on a case-by-case basis. But anything that is only used in a comparison is usually fine with saturating. And many things that measures values or work with measurements are fine with saturating if it's documented. Saturating is how most analog equipment works too, and in non-interactive use cases "just pick the closest value we can represent" is often better than erroring out or recording nothing at all.

Of course don't use saturating_add to calculate account balance, there you should use checked_add.

sfink

If you're going to check then you shouldn't be saturating, you should just be checking.

meta_ai_x

[flagged]