10 ms·
Safety and Soundness in Rust
- truculent 4y agoWould it be feasible to create a type which signifies that unsafe computation has happened? Like the IO Monad in Haskell, for example. This type would have to have a constructor that wraps a value and no way to take the value out of the type. That way, any code “infected” by unsafe computation will also be infected by the type.
- newpavlov 4y agoPractically everything will be marked by it. Using `Vec`? Marked. Reading file? Marked. Printing something to stdout? Marked. The idea behind Rust's unsafe is to cover unsafe bits in safe API which (assuming correctness of the unsafe code) can not be misused to cause memory issues in safe Rust.
- truculent 4y agoGot it, thank you
- LegionMammal978 4y ago> Rust is designed around safety and soundness. Roughly speaking, safe code is code that doesn't use the unsafe keyword, and sound code is code that can't cause memory corruption or other undefined behavior. I'd be a bit more precise here. Safety is indeed a property of some particular lexical part of the code, but soundness is really a property of an entire interface. A sound interface never causes UB when you invoke it according to its documented preconditions; an unsound interface may possibly cause UB in some situations even when invoked according to its preconditions. Some take a narrower definition of soundness, treating it primarily as a property of a safe interface: a sound interface that can be invoked from safe code can never cause UB under any circumstances. But personally, I prefer the broader definition, since it also includes unsafe interfaces which don't document all the preconditions they rely on. Knowing your interface boundary is an important part of writing unsafe code in Rust. For instance, suppose you have a data type with a method that unsafely makes a certain assumption about its fields. Then you have to be very careful to uphold this invariant in every other safe method that creates or modifies those fields. Furthermore, you have to ensure that no language-level operations on the object as a whole (e.g., swapping, dropping, forgetting, or covariant transformations) can break this invariant. But once you have locally checked all of those things, then you don't need to check any other part of the code base to know that your data type is sound. As another example, contrary to what some people say, it's not necessarily unsound to have a safe function that can cause UB on some inputs. You just have to ensure that external code cannot call that function (without having previously unsafely promised not to call it incorrectly), and no internal code calls it incorrectly. Here, the interface boundary is the entire section of your code that has access to the function. In particular, you sometimes see this with data types that have to implement safe traits using unsafe operations (for performance or for other reasons). Often, the function to create an object is unsafe, since you have to promise that the rest of your safe code does not invoke those trait implementations in such a way as to cause UB. Practically, you can do this by limiting how much of your code has access to the created object.
- mlindner 4y ago> Knowing your interface boundary is an important part of writing unsafe code in Rust. For instance, suppose you have a data type with a method that unsafely makes a certain assumption about its fields. Then you have to be very careful to uphold this invariant in every other safe method that creates or modifies those fields. Furthermore, you have to ensure that no language-level operations on the object as a whole (e.g., swapping, dropping, forgetting, or covariant transformations) can break this invariant. But once you have locally checked all of those things, then you don't need to check any other part of the code base to know that your data type is sound. This is simply incorrect. If it is possible to, in any safe code, invoke the invariants that can cause the undefined behavior then your code is unsound. UnSafe code _cannot_ rely on safe code invoking it correctly. ALL invariants must be covered at the boundary between safe and unsafe code, anything else is a soundness bug in the code. > As another example, contrary to what some people say, it's not necessarily unsound to have a safe function that can cause UB on some inputs. You just have to ensure that external code cannot call that function (without having previously unsafely promised not to call it incorrectly), and no internal code calls it incorrectly. Here, the interface boundary is the entire section of your code that has access to the function. This is again completely incorrect. If it is possible to invoke unsound behavior from safe code then the code is fundamentally unsound. This is the problem that the Actix developer kept trying to justify that got him in a lot of hot water with the community. Please never write code this way. If you have any public repositories please mention them so bugs can be filed against them. Put another way, a bug in any piece of safe code must never cause any piece of unsafe code to commit undefined behavior. That’s the fundamental guarantee that the entire language is based on. Please don’t violate that by lying to developers that code is safe when it‘a not.
- LegionMammal978 4y ago> This is simply incorrect. If it is possible to, in any safe code, invoke the invariants that can cause the undefined behavior then your code is unsound. UnSafe code _cannot_ rely on safe code invoking it correctly. ALL invariants must be covered at the boundary between safe and unsafe code, anything else is a soundness bug in the code. That's roughly what I'm saying? The point is that you have to carefully design your interface so that safe code from the outside is unable to break your invariants, no matter how it invokes your interface. > This is again completely incorrect. If it is possible to invoke unsound behavior from safe code then the code is fundamentally unsound. This is the problem that the Actix developer kept trying to justify that got him in a lot of hot water with the community. Please never write code this way. If you have any public repositories please mention them so bugs can be filed against them. If you have a public safe function that can cause UB, then it is indeed unsound. But it is possible to have a private safe function that can only possibly be accessed from a small part of your code base. If you are careful in preserving this invariant within the parts of the code that can directly access the function, then creating a sound interface without direct access to the function is entirely possible. (But this obviously gets more risky, the more code that has access to the function. I'll grant that it's not an uncommon pitfall to assume a crate-wide invariant somewhere that you later forget about.)
- codeflo 4y agoThis is a very good explanation. It's sad that these terms are so counterintuitive. I still regularly have to clarify the confusion between undefined and unspecified behavior. And to be fair, "define" and "specify" are basically synonyms in everyday language, it's evil that these terms have so wildly different meanings. Quite a bit of confusion is caused by people not fully understanding that "unsafe" in Rust actually just means "unchecked", i.e. not verified by the compiler, and not necessarily "insecure". Now we add "unsound" to the mix, and I fear that all of this is simply too hard to learn. Maybe we should reverse all those terms to be positive, and also use words in their intuitive meaning, and also avoid confusing almost-synonyms. Here's a quick attempt: 1. "Well-behaved" code would be code that only accesses memory correctly. (That's not fully precise enough. What I actually mean of course is "no UB", but that's hard to put into words if you don't already know what UB is. Let's say well-behaved code only does "allowed stuff".) 2. "Checked" code is code verified by Rust's compiler to be well-behaved if certain invariants are fulfilled. Code is always checked by default unless you opt out with a special keyword (confusingly called "unsafe"). 3. "Sound" code is well-behaved code (that may or may not be checked by the compiler) with the additional restriction that will remain well-behaved no matter how it's embedded into checked code. This is what enables "safe abstractions". Checked code is automatically sound by this definition, but alternatively, unchecked code could also be sound, which would have to be manually verified somehow. To do this, you obviously need to know which assumptions the checker makes in its reasoning. (Edit: Reworded a bit to address an unexpected source confusion. What's now called "well-behaved" was called "safe", but that has multiple meanings. Some of the responses in the thread apply to the old version.)
- LegionMammal978 4y agoI don't think your attempt would be quite accurate: safe code, even though it is checked, can cause UB. This can only occur if some earlier unsafe code was unsound, by the current definition. For instance, suppose you have an ordinary Box<i32>. Then, you dereference it to create a local &i32 reference, and use unsafe code to turn it into a &'static i32 reference. Then, you drop the Box<i32>, which deallocates the backing memory. Finally, you attempt to read the value from the &'static i32. The UB occurs only at the last step, when you read from deallocated memory. But reading from a &'static i32 reference is completely allowed in safe, checked code. That's why we use the term "unsound" to cast fault on the earlier unsafe code which allowed us to do this.
- dataflow 4y agoI feel like #![forbid(unsafe_code)] deserves a mention?
- oconnor663 4y agoThis starts to open up a whole topic that I though about including but ultimately decided not to: How do we deal with unsafe code in our dependencies? This gets into all sorts of tools like cargo-geiger and blessed.rs and things like that. Luckily, I think the Python/Java metaphors work well here. Rust apps have to worry about unsafe code in their dependencies, like Python and Java apps have to worry about C code in their dependencies. So hopefully not _too_ much is lost by leaving those questions for another article. Maybe I should add another footnote though.
- Animats 4y agoOh, if only that worked in practice. There's far too much use of "unsafe" in Rust crates. Sometimes, for really good reasons. Too often, because someone can't figure out a safe way to do something. Here's a thread I started on Reddit on this subject: "We're not really game yet".[1] Someone commented there: "There are people here who would volunteer to help you out, dig up the internals and address those issues, given you provide them with sufficient information." My reply: I've done that three times now: * jpeg2000-decoder -- JPEG 2000 decoder test fixture. This exercises jpeg2k->jpeg2000-sys->OpenJPEG. The first two are in Rust, the last one is in C, and valgrind shows it referencing un-initialized memory. It randomly segfaults. OpenJPEG has a long history of doing this, and has been the subject of several CERT security advisories. The author of jpeg2k has managed to contain the the problem by running OpenJPEG in a WASM sandbox. This keeps the program from crashing, but there is a 2.6x performance penalty. A bug report has been submitted to the OpenJPEG maintainers, who are funded by universities and companies but over 200 issues behind. * ui-mock -- game GUI test fixture This exercises rfd->egui->rend3->wgpu. It's a game GUI with menus and dialogs, but no game behind it, just a 3D drawing of a cube. It's useful for making bugs in that stack repeatable. That's been helpful in wringing out obscure bugs in egui. * render-bench -- scene update performance test fixture. This exercise rend3->wgpu->vulkan. It draws a city of identical buildings, then, from a second thread, periodically deletes half of them and re-creates them. If the stack is performing as intended, the updates from the second thread should not impact the frame rate from the main thread. But due to lock problems at the WGPU level, the frame time goes from 16ms to 700ms when the update happens. (Those are all projects on my Github [2], by the way, if anyone wants them.) Each time I have to do one of those bug-reproduction test fixture projects, it costs me substantial time not spent on the main project. Right now, I'm totally stopped by a race condition crash bug not in the list above, one for which I don't have a standalone project which can duplicate the bug. They're trying to fix it, but for now I'm stuck. I may have to build a custom test project. But it will be tough, because it's a timing-dependent bug that only appears under load. This is why no one has successfully done a major game on Rust. The foundations are too weak to support it. The right stuff is there, but much of it is stuck at "sort of works". It's a real question whether game development in Rust will ever have tools that Just Work, or whether parts of the ecosystem will go directly from "sort of works" to "abandoned" without ever reaching Just Works. Safe Rust really works. My own code is 100% safe Rust, 36,000 lines of it. No obscure crashes in my own code. When I've needed gdb or valgrind, it's always been due to a problem in someone's unsafe Rust or C code. There are people who think they're so good they can write unsafe code. They're not. [1] https://www.reddit.com/r/rust_gamedev/comments/11b0brr/were_not_really_game_yet/ https://www.reddit.com/r/rust_gamedev/comments/11b0brr/were_... [2] https://github.com/John-Nagle https://github.com/John-Nagle
- ridiculous_fish 4y ago> Instead, the only way I can think of to make foo1 commit UB is to give it an uninitialized index Nah a `fclose(stderr)` before calling it will do it! > A safe caller can't be "at fault" for memory corruption or other UB. An example of where a safe caller can be at fault is after fork, or in a signal handler. Functions like malloc must not be called, but Rust does not model this and freely invokes malloc from safe functions. In these regimes safety is inverted: File::open is NOT safe, and may invoke UB; while the "unsafe" libc::open IS safe and should be used instead.
- burntsushi 4y agoI wouldn't agree with this. In order to register a signal handler in Rust, you have to utter unsafe somewhere. That's the point at which you form the contract that your signal handler is correct. The fault is not with the safe code that allocates. The fault is with the creation of the signal handler. It is true that Rust could model signal safety in some richer way, but that's just a matter of shifting where the contract is formed.
- ridiculous_fish 4y agoThe author gives the following function: static BYTES: &[u8] = b"hello world"; fn foo1(index: usize) -> u8 { BYTES[index] } and then writes we can't make foo1 commit UB just by giving it a large index ...foo1 will never cause UB without the caller committing UB first. But strictly speaking this is wrong! If I plug foo1(large_index) into `process::Command::pre_exec()`, then the resulting panic may invoke UB, without the caller having committed UB first. So you have to decide whether your function is designed to be run within these environments. If it is, and you add an allocation, then that's your fault!
- oconnor663 4y ago> But strictly speaking this is wrong! I agree, and I'm making a correction. Instead of "foo1 will never cause UB without the caller committing UB first", the article now reads "foo1 will never cause UB without the caller writing unsafe first." Thank you!
- satvikpendem 4y agoYou could just use cargo-geiger and #![forbid(unsafe_code)] in order to only have safe code. We use this in our CI/CD in order to ensure that no unsafe code is used.
- oconnor663 4y agoI replied to a related comment here: https://news.ycombinator.com/item?id=35034184 https://news.ycombinator.com/item?id=35034184
- lordnacho 4y agoWhat happens if you find unsafe deeply nested in some dependency that you need? Like a web framework or maybe a graphics engine that's not so easy to find a replacement for?
- satvikpendem 4y agoWell, the simple answer is you don't use it. The more complex answer is that you do use it but have safe guards in place, which `unsafe` does to some extent even more than C or C++ code. You could also ask the creators of said dependencies why there is `unsafe` in their code and perhaps submit PRs to mitigate or remove that, as in the case of actix-web a few years ago. But as Rust crates become more prevalent, I do expect that we'll see fewer and fewer crates with `unsafe`, or have enough safe alternatives to `unsafe` crates.
- firstlink 4y ago> you do use it but have safe guards in place Isn't this the same thing that everyone else is doing, except they're not pretending to be special by falsely claiming that they're using no unsafe? My impression of `forbid(unsafe)` users in practice is that they think they've cleverly discovered a way to encapsulate unsafe that everyone else is missing, when in fact they've missed the point of unsafe to begin with and then belatedly rediscovered it.
- 4y ago
- pornel 4y agoI really like that in Rust the API safety is defined by the language, not by each library individually. There are no gotchas to be found in small print in the library documentation, no disclaimers that the code is meant only for single-threaded programs, or haggling over how rare the edge cases are. If it doesn't say `unsafe`, it has to meet Rust's universal requirements for safety.
- vilunov 4y agoThat holds only for properties which are describable by the Rust's type system, including mentioned thread- and memory-safeness. Things get worse when it comes to async and futures, since RAII doesn't really work with them, and you have to consider cancellation-safeness of libraries' futures, which are noted in documentation only.
- pornel 4y agoSafety includes everything that could cause UB. Things that cannot be safely expressed are forbidden. Async too: you're not allowed to make cancellation-unsafe futures that could keep using a buffer after being dropped. This is why io_uring abstractions only work with custom buffer pools and not slices.
- hulitu 4y ago> Rust is designed around safety and soundness. Then why are crates automatically updated from internet ? This is not safety, this is remote code execution.
- vilunov 4y agoCargo doesn't update crates automatically.
- nyanpasu64 4y ago`cargo install` updates crates automatically, unless you add the non-default --locked parameter.
- firstlink 4y ago`cargo install` is a one-shot build of a program and all of its dependencies from source; in what sense does it "update" any crates?
- nyanpasu64 4y ago`cargo install` doesn't install the locked dependency versions tested by the program author, but ignores Cargo.lock entirely and automatically updates all dependencies to the latest versions (usually semver) allowed by the Cargo.toml file.