Hacker News new | ask | show | jobs
by steveklabnik 14 days ago
I think this is a fine post. But one comment:

> remember that for compilers which emit machine code, like roc and rustc, doing memory-unsafe things is a big part of the job

I don't really think that this is true, in the way that it's written.

I think that for the hot binary patching / code reloading features, yes, that is going to need unsafe. But for regular old "producing an executable" compilation? Emitting machine code isn't the part that requires unsafe. The language's runtime is a more likely site to find unsafe.

8 comments

> I think that for the hot binary patching / code reloading features, yes, that is going to need unsafe. But for regular old "producing an executable" compilation? Emitting machine code isn't the part that requires unsafe. The language's runtime is a more likely site to find unsafe.

Agreed! Emitting machine code is not unsafe, since it's just writing bytes down - it's only once you execute that machine code that there's potentially unsafety. The reason I said "a big part of the job" is that in practice a lot of compilers both emit machine code and execute it - but you're totally right that it's not a requirement that a compiler do both.

In addition to the examples you gave (hot binary patching/code reloading, language runtime, etc.), others would be things like evaluating userspace code at compile time (e.g. const fn in Rust, or in Roc any expression that could be hoisted to the top level), running tests and inspecting their output to decide what to display to the user, etc.

Those are the types of things I had in mind when I wrote that.

I am disappointed you're downvoted, Richard. This is a fine reply, and I hope you know that a minor quibble with a single line in the post doesn't mean that I think it's a bad one overall. (EDIT: a few minutes later, the parent comment is no longer grey.)

I also think it's a good thing that you wrote the post in general, when I saw it pop up I was like "oh, of course, this post should exist!" I'm surprised I didn't think about it earlier.

> evaluating userspace code at compile time

Usually this would be done via an interpreter, so I'm not sure that it really requires unsafe either. If you are literally executing machine code, sure, but const fn in Rust and constexpr in C++ and many other languages do not do that, as it causes a number of problems (for example, cross-compilation).

Also a good point! TIL that Rust and C++ use interpreters for const, although of course that wouldn't work for running tests. Then again, in the specific case of Rust I believe rustc only compiles the tests and then something else like Cargo executes them. Of course, as I noted elsewhere, if rustc emits machine code and then cargo immediately executes it, there's the same opportunity for end user memory being corrupted (due to miscompilation) as if rustc and cargo shared a code base.

By the way, I thought your question was totally reasonable - my first thought reading it was "Oh yeah I wasn't trying to say that writing bytes is unsafe, I definitely should have worded that differently."

Cool, I'm not sure that people know that we know each other and have some deeper mutual understanding. :)

> although of course that wouldn't work for running tests.

Why not? Unless you mean in the cross-compilation case, in which yeah, to run the compiled tests you'd need an emulator.

> in the specific case of Rust I believe rustc only compiles the tests and then something else like Cargo executes them.

It doesn't have to be Cargo, but yes, rustc produces executables for the tests, and you have to then run them.

> there's the same opportunity for end user memory being corrupted (due to miscompilation)

I agree for sure that the safety of the outputted binary is completely distinct from the safety of the compiler itself.

I think the reason that this framing specifically (in the post and in this comment) strikes me as odd is that "requires unsafe code" sort of implies that you need to use unsafe to fix the unsafety of the outputted binary. That just isn't the case. Of course, this is a serious bug that needs to be fixed, but there's just something about "doing memory unsafe things" in this area that like, I think can be a little mis-leading, even if that's not intentional. But I am going to sit with this and think about it, regardless, because I am not sure that my gut reaction here is completely accurate.

(And, hilariously, looking over some work my agents did on my compiler last night, they fixed some mis-compilations that occurred, entirely in safe code. I bet that's also part of why I'm in this headspace at the moment, it's not like those fixes required dropping down into unsafe to fix either!)

> if rustc emits machine code and then cargo immediately executes it, there's the same opportunity for end user memory being corrupted (due to miscompilation) as if rustc and cargo shared a code base

Your tests run in an entirely separate process from the compiler (and from cargo). This makes it very different from memory corruption in the compiler:

- The test process can only corrupt its own memory.

- You don't need "unsafe" to run tests. Just the ability to start another process.

- If you're cross-compiling, you wouldn't even be able to run the tests on the same machine (without emulation/compatibility layers)

Does roc run tests in the same process as the compiler?

> Does roc run tests in the same process as the compiler?

We do for tests of pure functions, yes.

> Your tests run in an entirely separate process from the compiler (and from cargo).

That's a great point and a relevant distinction, although Rust tests can run arbitrary I/O, so it's not like having them be in a separate process means memory corruption is harmless! :)

True, but not sure how it's relevant - you don't need memory corruption for the test to perform arbitrary I/O. And even if you trust the tests to not do anything bad, you don't need memory corruption for this to be an issue - any bug in the code emitted by the compiler could cause bad things to happen.

But all of this is ultimately completely unrelated to the concept of "unsafe". You can delete your home directory just fine in safe Rust/Go/Python/etc. You can write a compiler that emits broken code in the same languages; even in a 100% pure functional language with 0 side effects and a perfect bug-free implementation.

I would like to understand this more,

> rustc emits machine code and then cargo immediately executes it, there's the same opportunity for end user memory being corrupted (due to miscompilation) as if rustc and cargo shared a code base.

Cause this hasn't been true for me or for anyone maybe your definition of memory being corrupted is the not same as mine.

I am not even sure what you are trying to prove with this.

I appreciate the time and effort in building stuff like Roc I don't use it but this comment and the article feel like...

Oh some guy said Zig not nice because memory safety so here, a post why memory safety doesn't exist because we have to do memory unsafe things sometimes and so everything is memory unsafe already, so maybe it doesn't matter.

I get the energy that we are going for seeing useless claims and wanting to push back but I think the article deserves a clearer part 2 where you elaborate on your thoughts about stuff maybe even get it peer reviewed a bit before posting or maybe don't I guess we could use more raw thoughts in the post AI age.

Either way I appreciate someone trying to put forward their own thoughts and explain problems with a different perspective.

Scenario A: A program has a memory vulnerability. That's bad, and the reason it's bad: attacker-controlled data can potentially trigger this vulnerability, which could even lead to privilege escalation.

Scenario B: A program writes machine code in an executable region of memory, and the code has a memory vulnerability. That's bad, and the reason it's bad: attacker-controlled data can potentially trigger this vulnerability, which could even lead to privilege escalation.

Scenario C: A program write machine code to disk, which is then read into memory and executed. That's bad, and the reason it's bad: attacker-controlled data can potentially trigger this vulnerability, which could even lead to privilege escalation.

Or scenario D:

A text editor stores a URL in a location on disk, and another component then fetches and executes that with its current privileges.

How is that a memory safety issue in Rust compiler? What point are we making here. Am I just too dense to understand, is how the hell is that a need unsafe issue? Or anything to do with compilers???
> Usually this would be done via an interpreter, so I'm not sure that it really requires unsafe either.

Well, I personally have written a const-expression evaluator that actually reuses the rest of the compiler: it compiles the expression in the current environment with some specific adjustments to the codegen settings, launches the temporary executable and gathers its output... frankly, it's more hassle than it's worth compared to writing a separate const-expression interpreter. Plus, of course, it also runs slower since most constant expressions are usually pretty trivial.

It depends somewhat on the complexity of the language.

Nim uses an interpreter but Nimony, which is destined to becomes Nim 3.0, uses your approach. It will be interesting to how hassles and performance play out there and whether they keep the compile-and-run approach or go back to an interpreter.

Side concerne, but reading the post initially I thought it was about Rocq (formerly Coq). Maybe both team could coordinate and find a way that everybody agree on to avoid any confusion?

https://en.wikipedia.org/wiki/Rocq

Many people try to twist the fact memory safe languages have unsafe code blocks to make the pivot that why bother.

It is like someone arguing that since they always bump the head somehow while wearing seatbelts, then they are only a nuisance and should not be used.

Because they view "unsafe" as an escape hatch instead of a feature. It's a way to encapsulate dangerous behavior, tightly, with clear postcondiitions. Sometimes it's the only way to do things like interact with inherently unsafe FFI code, or hardware.
I adore unsafe, appreciate it as a feature... but it is an escape hatch. One that is sometimes necessary, one that is sometimes not necessary but might still be (ab)used for performance, or initial 1:1 porting of C/C++ code. There are a lot of cases where that escape hatch should probably welded shut though. Fortunately, the Rust ecosystem has tools like `cargo geiger`, and straight out of the box I can also write:

    // src\lib.rs
    #![forbid(unsafe_code)]
I feel like this perpetuates a bad mental model. Unsafe is not an escape hatch. Code within unsafe blocks must uphold the same semantics as code outside it but the compiler cannot guarantee that those semantics are upheld.

If we're using analogies, `unsafe` is like a "hard hat required" sign. There's nothing intrinsically different about the space inside or outside of it, other than that you can't be sure a brick isn't going to fall on your head once you cross over. So it's on you to wear a hard hat. And to not drop any bricks and trust other people to do their best not to drop any bricks.

You wouldn't call that an escape hatch.

Yeah, I'd have more sympathy for the "It's an escape hatch" model of this feature if the C++ approaches to this problem didn't keep adding actual escape hatches and waving away concerns as "Rust does this too so it's fine".
It can be abused as an escape hatch, but it really shouldn't be.
I think it's a reaction to many simpler advocates of Rust saying "it's unsafe and irresponsible of you to not use Rust, you can't write software in C or C++ or Zig (or Go?) and have memory unsafety and put your users at risk, it's not safe, you have to use Rust to be safe".

Freakin' safety. I like doing lots of unsafe things in life: rock climbing without gear (short bouldering sections on hiking trails), bicycling and skateboarding without a helmet, etc. I developed pretty good skills in all these things, and programming C too. What kind of life would be worth living with enforced perfect safety? Without developing skill in life's many un-safe activities?

So people want to emphasize that, if they shouldn't use non-Rust languages because their safety can't be guaranteed, well your safety using Rust isn't absolute, either. So you can't tell me I must use Rust, or the Rust rewrite of my favorite tools, to "be safe".

Do you recall the early rust web framework, that used a lot of "unsafe" "inappropriately" and had some vulnerabilities ... actix web? ... reminds me of the bun-in-zig situation, kinda makes the language's PR situation more complicated and tricky.

There's a material difference on one taking risks for oneself and one taking risks where the brunt of the consequences fall on others.
You mean like driving a car?
I don't think using an example that includes licensing, plenty of regulations governing drivers, the cars themselves and the built environment, enforcement, and plenty of research on the impact of passive infrastructure to make things safer (bollards, daylighting, raised pedestrian intersections, curbs, traffic lights, etc.) makes the case you seem to be trying to make.
Yep: there's a difference between driving a car in traffic and driving a car in a rally or in an endurance race. There will always be some rules and regulations unless you're on your own privately owned race track with no one else on there, but the regulations can differ greatly depending on the situation.
Memory safe languages, where usage of Miri and Valgrind (tools to for instance debug memory unsafety) are common and integrated into CI for some of the projects in the language. Even some Rust guides encourages running Miri in CI https://microsoft.github.io/RustTraining/engineering-book/ch... . Searching on GitHub yields a lot of projects that run Miri in CI. And there have been a lot of CVEs for Rust projects caused by memory unsafety.

Interesting perspective that you have.

People still die while wearing helmets and seatbelts.

Yes, those unsafe blocks should be cross checked, but lets not pretend it is the same as writing C or C++ where each line of code is a possible CVE.

I agree that it’s not inherent to emitting machine code but I do think it reflects a different set of priorities.

In extremely high performance code you use different data structures and algorithms and change your approach to memory allocation. TigerBeetle famously does all memory allocation once on startup.

Roc is attempting to make a similar set of trade-offs in their compiler as Zig, so it makes sense that the author finds many shared patterns.

I do think it reflects different priorities, but one of those differences is that from my perspective, safety and performance are not inherently at odds. Yes, sometimes it is needed, but not as much as some people seem to think. Sometimes, it also means writing code in ways that communicate things to the compiler that you may not think of if you're not used to thinking in this manner.

A lot of the ways in which the zig compiler works doesn't use pointers, it uses indices. This stuff is easier to write as safe code, not less easy.

> Roc is attempting to make a similar set of trade-offs in their compiler as Zig, so it makes sense that the author finds many shared patterns.

I do think that that makes sense, but it also doesn't mean that they have to. I am doing a compiler project that takes a lot of inspiration from Zig (as my language currently inherits some major things from Zig, and I also care a lot about compiler performance) and it's written in Rust, and does not use much unsafe code (outside of the usual suspects of FFI in the runtime, etc).

> A lot of the ways in which the zig compiler works doesn't use pointers, it uses indices. This stuff is easier to write as safe code, not less easy.

I respectfully disagree. This is only true if you view malloc as qualitatively different from a piece of code that gives you an index for a free object in an object pool.

Provided you don't ask/give back memory from/to the OS, what malloc is doing is giving you an index (pointer) into a pool of bytes, while manipulating an internal bookkeeping structure.

Use after free is just you using an index after said bookeeping structure has marked that piece of memory as available for something else (and perhaps claimed already).

If you have an array of Node structs to represent a graph (like the AST in Zig), and use indices to represent references, you have essentially zero protection in Rust that helps you with finding Node-s that have been used for something else etc.

The 'asking memory from the OS' aspect for malloc doesn't really change the safety of your language compared to this where it matters - if you do use-after-free on a page claimed by the OS, you get a segfault, which immediately tells you there's a problem, which is much better than silent corruption.

At least with malloc, you get debug allocators, or other features that can help you in this case. If you are careless with indices in an object pool, and overwrite stuff, essentially, it's up to you to figure out what went wrong and you have no tools to help you.

I fully agree that there are similarities here. But we disagree about some of the details.

> you have essentially zero protection in Rust that helps you with finding Node-s that have been used for something else etc.

The most obvious technique is generations. You can of course do that in Zig as well.

> if you do use-after-free on a page claimed by the OS,

This assumes that you're working in the context where there is an OS. That isn't always the case. Also, there are other cases than just use-after-free: for example, compilers will optimize around null pointers being UB, which can cause other problems, whereas an index of zero does not get the same treatment.

But also, again: Zig does not use malloc for its ASTs, as far as I know. It uses lists and indices. I haven't literally read the code lately myself, but I would be surprised if they went back to malloc'ing individual nodes.

> In extremely high performance code you use different data structures and algorithms and change your approach to memory allocation.

It's worth noting that the reason Rust doesn't include support for custom memory allocation patterns like Zig does has nothing to do with memory safety. It's more of a historical accident that it just wasn't something that was prioritised early in the projects history and is now hard to change.

It's also important to note that Rust as a language does not know about the heap at all, it is purely a library concern. This means that "doesn't include support for custom memory allocation patterns" is purely talking about standard library data structures, and if you need a ton of performance, you're probably going to be writing your own anyway.

It will be nice when the Allocator trait stabilizes so that the ecosystem can coordinate on making this stuff pluggable, but that's not a direct blocker for getting things done if you need to do things today.

No disagreement in principle; in practice taking that approach in Zig is safer than in Rust, because in Rust it’s “all or nothing.”
The interfaces to custom allocators are more idiomatic, standardized, and normalized in Zig, but there's nothing systematically unsafer about the Rust equivalent. You can use custom allocators all you want in Rust, as long as you're okay using third-party crates like Bumpalo.

On that topic, worth mentioning that Rust's long-awaited `Allocator` trait is perilously close to stabilizing; watch for https://github.com/rust-lang/rust/pull/157428 to be merged, then the stabilization PR to progress here: https://github.com/rust-lang/rust/pull/156882

Yeah that is definitely 1000% wrong. A compiler can do its job with totally abstract data structures. If anything would need to do unsafe stuff in memory, it would probably be a linker.
> probably be a linker

I don't think that's any different either. The core job of linking isn't particularly unsafe.

(Unless, similarly, you're doing the hot reloading stuff)

I've noticed that people equate "low level stuff" with unsafe, regardless of whether it's contextually justified.
I think it's an understandable prior. Historically, "low level stuff" was near-exclusively (see my comment below about OCaml...) written in unsafe languages. Even if that wasn't always literally required, it sometimes was, and so thinking this is the case was a reasonable thing to think.

It is only relatively recently that we have gained more realistic options in these spaces, and so not fully understanding the implications, or preferring the historically normal choices, is understandable.

I'll play devil's advocate. I think emitting machine code intended to run is unsafe because you could emit unsafe machine code, which could run. It's the whole system that is either safe or not, not the individual components. If your system gets hacked by a buffer overflow in the end, nobody cares whether it was the linker that overflowed or the code emitted by the linker.
"Safe" has a very specific definition in Rust. It's not identical to the broader definition used in technical English. You can easily have safe rust code with behaviors any reasonable layperson would call unsafe, like crashing a plane. The original article, comment, and replies were using the word in the Rust sense from my reading, not the English meaning.
Then that's equivocation. Why do we want a very specific form of safety instead of wanting safety in general?
That would mean no language can ever be considered safe, because any language can emit bytes to a file that will later be executed.
Correct. Safety is a system property, not a language property. Calling a language safe is about as sensible as calling a metal alloy unsinkable.
The only safe program by this measure is the one that's never ran.
> It's the whole system that is either safe or not, not the individual components.

This is a core perspective disagreement. While this is true:

> If your system gets hacked by a buffer overflow in the end, nobody cares whether it was the linker that overflowed or the code emitted by the linker.

That does not mean that increasing the amount of safety in the individual components isn't helpful, because it helps minimize the above outcome, even if it will never be zero.

Safety is a feature of a system - yes. It's also a property of what it's against. A computer could be safe against being hacked but still be dangerously easy to drop on someones toe and break it.

Safety [against something] is also a feature of components - a system made up of only safe components [against a thing] is safe [against the same thing... I'm going to stop this qualification now for brevity]. A system containing unsafe components may or may not be safe but at least you know what components usage you need to look at carefully.

If your linker is safe, linking code will never result in the thing it is safe against. Ever. This is a useful property even if running the linked thing is not safe because it means:

1. When things go wrong in strange ways, you have strict bounds guiding you in figuring out what went wrong.

2. You can build reliable systems that do part of the job, and only have to sandbox the other half of the job. Compiling in a CI system will (if the compiler was entirely safe) be safe. You can do it with secrets present against malicious code. Running tests will have to be sandboxed (assuming running tests isn't safe). This could for instance enable safely sharing significantly more artifacts for incremental builds in CI.

Unfortunately very few compilers are really safe against anything (though I do wonder how I could break my toe on one). Rustc for instance has a giant C++ half called llvm that isn't really hardened at all. We get away with this by just not trusting the compiler when run against potentially malicious code.

Perhaps the parent meant dynamic linker.
I was thinking a classical linker that e.g. combined sections together after already laying out subsequent sections. That would basically be an memmove. It doesn't necessarily have to be unsafe, but I could see it being implemented that way just to keep things simpler on the rest of the data structures. (Though if you said that that was an indication of incomplete design I'd have a lot of time for that argument.)
It wouldn't be the linker that has to be unsafe, it'd be the "and now execute!" jump. And that could be abstracted as memfd+execveat, which are fairly normal operations.

OP's argument is roughly "doings things with computers has to be unsafe to be useful", which is.. uninteresting.

That line confused me, too. What parts of their compiler require memory-unsafe operations to produce machine code?
They are saying that running the compiled code is memory-unsafe when there is a compiler bug, and that’s what developers do next. The memory corruption happens in a different process.

In this respect, effectively all the compiler should be treated sort of like an unsafe region because it requires extra care to avoid memory corruption bugs.

That's not what it says at all. The section we're talking about is for the compiler and emitting machine code

> we ended up with about 1,200 uses of unsafe

> remember that for compilers which emit machine code, like roc and rustc, doing memory-unsafe things is a big part of the job

Anywhere talking about the `unsafe` keyword is within the Rust code.

The article is a bit confusing because they also write:

> Regardless of which process had the bug—the compiler or compiled program—in both cases the processor only did the bad thing because the compiler told it to. And in both cases the fix is the same: the compiler's code must change, since that code was what caused the memory corruption.

But yeah, I wonder what those 1,200 unsafe uses actually did?

I think if you interpret it charitably it means that any bug in the emitted machine code is already a likely memory-unsafe miscompilation if it is ran.

The compiler itself might be perfectly "memory safe" but the generated binary fundamentally is always at risk (besides WebAssembly I suppose).

I'm fully aware of the separation of compiler and binary, and being able to compile untrusted code safely is nice, but a perfectly safe compiler that generates vulnerable binaries isn't that much better.

I do think that is a good point, it's just not what the line actually says. But that's why I wasn't saying "zomg this is WRONG!!!!" but instead, trying to point out that there are subtleties here. For people who aren't as deep in the weeds in this subject, I think the details matter. But again, as I said, I like the post, this is just one thing.

I am also probably in a more pedantic mindset because, well, I'm writing a compiler in Rust, and the words as written do not resonate with me at all.

> a perfectly safe compiler that generates vulnerable binaries isn't that much better.

I do think it's much better. Eliminating classes of bugs in one component is a good thing, even if it's not every component. This is a core lesson of Rust! unsafe still exists, but going from "I don't know what is unsafe" to "only this part is unsafe" is a major improvement.

In context that's clearly not what he's saying, the next sentence is this:

> Zig has more features than Rust for making memory-unsafe code work correctly, and that was the area where we wanted the most help.

Zig definitely does not have more features for successfully emitting memory-unsafe machine code than Rust does. I can emit memory-unsafe machine code from typescript if I really want to and nothing at all in the language will get in my way. So the sentence quoted above must refer to the idea that the compiler itself needs to be unsafe, which Steve is right is simply untrue.

The section this came from was talking specifically about usages of `unsafe` in the compiler code.

It's not about the memory safety of the resulting binary.

> I don't really think that this is true, in the way that it's written.

Well, it's true when it's written badly (eg. the same way as when somebody writes in-line "shellcode" by doing `*(T*)ptr = (T)val;` or similar.

I think there's some history of some compilers that were basically non-trivial to port to other architectures/bit-sizes b.c. of this piggybacking.

Agreed, that’s disturbingly incorrect.

If anything, compilers are perfect models of trees and well formed programs.

Maybe in theory. In practise you have thing like super optimisers. You have side effects that the compiler needs to understand etc.

That said I'm struggling to think of something that would need to be unsafe.