Fil-C and Rust: Where's the safety at?
AuthorTiago Cerqueira
For a long time there was an axis that every language sat on, safety on one end and performance on the other. And the core difference is how each language chooses to do memory management. Do you want maximum performance? You have to manage memory yourself and use pointers to memory. Do you want more safety? Add a garbage collector tracking object lifetimes for you.
Rust moved off of this axis: you get performance like C++ because you have control over memory, but you don’t sacrifice safety. The compiler proves your program is safe while keeping dynamic allocations and the convenience with which C++ won over the other, safer alternatives that tried to compete in this space.
Statically speaking
It’s important to understand that Rust achieves this at compile time, or statically. If it can’t prove your program is safe it will not compile it, and the compiler failing to prove the safety of your program doesn’t mean it’s unsafe, just that the compiler can’t prove it 1. There’s a really nice way to visualize how C++ and Rust approach this problem.
a lot of the time the programmer is doing something “funny” and there’s a good chance you can model it in a safe way
Rust’s subset only accepts safe programs and leaves out some that are safe but not provably so. C++’s subset accepts all safe programs plus some unsafe ones it can’t tell are unsafe. Rust tries to expand the safe programs subset while rejecting all unsafe ones. C++ tries to shrink the unsafe programs subset while accepting all safe ones.
So, can we draw a perfect line and design a language that statically accepts only the safe subset? Technically yes, but the answer is closer to “no” if you’re not willing to sacrifice what made these languages convenient and popular over safer alternatives. (Sorry for the unsatisfying answer, but this is a really deep rabbit hole).
Fil-C
What does Fil-C’s subset look like? The same as C/C++’s: it doesn’t add anything statically, it inherits everything. Does that mean it’s as unsafe as C++? Statically speaking, yes, but it adds a different layer of safety, one that manifests at runtime. This is the important distinction.
Fil-C positions itself as a solution to make existing C/C++ code safer. There are millions of lines of C and C++ code in the wild that can’t “just” be rewritten. Fil-C promises that by recompiling your application and dependencies you get safety at a comparatively small cost.
It builds on top of clang by adding a custom pass to the compiler where it injects metadata, called InvisiCaps, that is checked at runtime when you access pointers. It’s short for “invisible capabilities”, because they are invisible to the program and the capabilities are the bounds that the pointer is allowed to access. I like to think of it as a performant runtime provenance checker; if you’re coming from Rust, it has similarities with MIRI, although with a different purpose and implementation that result in better performance characteristics that make it viable not just in the development cycle. It also adds a fast concurrent garbage collector to enable use-after-free detection, among other details.
Fil-C does not statically check your program (technically it does some extra passes but it’s not where it shines). So it’s a completely different tradeoff. It will not prevent you from trying to dereference a null or dangling pointer, it just contains the damage by panicking instead of triggering undefined behavior.
Worth clearing up a misconception I’ve seen online, that a Segmentation Fault is equivalent to a panic – these are different. A panic is a deterministic halt of your program; a Segmentation Fault is when the OS catches you accessing memory on an unmapped page, which is the best case scenario, but the memory you are incorrectly accessing may well be valid memory and the program keeps going in a corrupted state. Segmentation Fault usually means you were lucky and the undefined behavior was stopped – but it could corrupt other parts of your program and take over the process, so for attackers this is a strong signal that they can breach your system’s security, while with a panic the best you can do is a denial of service. Attackers’ creativity in exploiting an innocent-looking crash can’t be overstated. It escalates very quickly.
But for a kernel, a medical device or a car brake embedded system, the panic is the vulnerability and they can’t afford it. Catching the issues early, at compile-time, is non-negotiable.
So this is good, Fil-C turns exploitable bugs into deterministic panics. Did we just solve memory safety? Well… We already had Java and other safer languages that don’t allow arbitrary memory manipulation and use garbage collection, so this is the wrong question. What forces industries to adopt C and C++ is constrained environments and strict requirements, for example, safety-critical or high-reliability systems – for these, a panic is still unacceptable. What we want is a language that prevents (not just mitigates) memory bugs (and others!) and operates in constrained environments. Is Fil-C suitable for constrained environments? At the time of writing, Fil-C has no unsafe escape hatches (more on that later) and the performance penalty is in the 4x ballpark (pretty impressive still!) with 1.5x in the best cases – which may not be suitable for low-power devices or real-time systems 2.
In theoretical terms because I’m ignoring the fact that Fil-C only supports the linux-x86_64 architecture – it’s a technical limitation that may be lifted in the future.
So how safe is Fil-C at runtime? Even the Fil-C author’s definition of memory safety is a bit skewed:
“memory logic bugs don’t give the attacker control over all of memory”
It focuses on the exploitability of the bugs, with no mention of correct memory management. What about bugs that just corrupt other parts of your program without making it exploitable? What about this:
// thank you Steve Klabnik for the example
typedef struct User;
int
This is not trapped. If you pass 012345678, it clearly overwrites memory it shouldn’t write to! This is a limitation of Fil-C for tracking memory only at the allocation level, without the per-field granularity. Is it safe?
What about data races? Data races under Fil-C are not considered memory unsafe but they are undefined behavior both in C/C++ and Rust. From the Manifesto:
“Pointer races on pointers not marked
_Atomicorvolatilelead to Fil-C panics, at worst.”
A pointer in Fil-C is stored apart from its capability, which lives in an aux allocation but they go hand in hand. If you race the pointer to the allocation, you get a panic when you later try to dereference outside the bounds of the capability, or nothing if it happens to be inside the bounds. So Fil-C doesn’t trap data races per se. It just keeps the same promises as before: if the pointer and the bounds don’t match it traps, otherwise it thinks it’s ok. Any other type of data that is not a pointer doesn’t involve a capability at all, so there’s nothing to check – same blind spot as is_root, from the other direction. In contrast, Rust catches them at compile time 3, through &mut semantic uniqueness and the Send and Sync traits.
important distinction between “data races” and “race conditions” – Rust only saves you from the former. Data races are a violation of the memory model, when two or more threads read and write non-atomically while race conditions are a logic bug and depend on the intended behavior of the program.
Escape hatches
The author markets “no escape hatches” as an advantage because it makes it “safer” than Rust, and goes further, claiming that Rust is not really safe because of it! Well, I think that’s quite a stretch.
Rust’s model is safe, no question about it. When we write unsafe blocks in Rust we are not making it unsafe – we are enabling the superset of the language that allows operations the compiler can’t prove to be safe, and we are now responsible for ensuring that ourselves; it’s an escape hatch for programs that are hard to express in safe Rust. The compiler is simply pushing the responsibility onto us, which is not very different from the default and only mode of C and C++, but it has an advantage – it’s explicit, we can contain it and audit it.
In real-world Rust code, unsafe is not (or shouldn’t be) spread across the entire codebase (if that’s the case you should consider another, more suitable, language) but instead scoped and abstracted behind a safe interface that is always safe, no matter the inputs or what happens.
Let’s see a real-world example of what I mean:
// more or less the stdlib implementation without indirection
We take a contiguous region of memory and we want to split it in two. Why the unsafe? In Rust mutable references must be unique, if the slices we returned overlap we could modify the same region of memory simultaneously, that violates Rust’s model and breaks the compiler assumptions – so it’s on us to tell the compiler “trust me bro” and guarantee that the properties of the Rust model hold. We package it in a safe interface and consumers trust it.
So, Fil-C has no escape hatches? Well, technically it does, but it’s confined to the runtime internals and users can’t tap into that power. This is basically moving the trust boundary totally out of reach for users while Rust keeps it in a subset of the language. Is one better than the other? Depends on what you do, but there are trade-offs. In Rust it is at least explicit and it can be scoped. In Fil-C’s case the scope is the runtime and no one else can use it, and you always pay the cost. But you still have to draw a boundary somewhere, whether it’s who wrote a runtime or an unsafe block.
Another argument floating around concerns soundness bugs in the compiler that let contrived safe programs violate memory safety without a single line of unsafe, namely cve.rs. This code doesn’t appear in the wild and it’s extremely unlikely you’ll face it, but it fueled the debate as “proof” that Rust is, again, not really safe… This is another misrepresentation. The implementation is not the same as the model. If a compiler bug creeps into Fil-C, can I claim it is no longer safe? Of course not – it’s a bug that broke Fil-C’s model. Fil-C rests on the correctness of the runtime to have safety, Rust relies on the compiler’s correctness – if these soundness issues disqualify Rust as safe, they should disqualify Fil-C as well.
Conclusion
The competition over who is more “memory safe” is not even apples to apples. Fil-C is on a different axis that makes substantially different tradeoffs. Rust bets on compile-time safety and Fil-C bets on runtime safety, that’s it. Rust could add runtime safety on top of its compile-time safety, while Fil-C couldn’t implement compile-time safety on top of its runtime safety without giving up backwards compatibility.
Anyway, here’s the side by side summary:
| Category | Rust | Fil-C |
|---|---|---|
| Use after free | Compile time – lifetimes and the borrow checker | Runtime – free zeroes the capability’s bounds, every later access traps |
| Null / dangling dereference | Compile time – there is no null or dangling references | Runtime – panic on the dereference |
| Out of bounds, across allocations | Runtime – indexing is bounds checked, and no pointer arithmetic to get there | Runtime – the capability carries the object’s bounds |
| Out of bounds, within an allocation | Runtime – same bounds check, and no way to address a sibling field | Not caught – one capability covers the whole allocation |
| Forging a pointer out of an integer | Compile time – the type system | Runtime – the forged pointer has no capability, so it can’t be dereferenced |
| Data races | Compile time – &mut uniqueness, Send and Sync | Not caught – the outcome stays memory safe, a race on a pointer panics at worst |
| Failure mode | a compile error, or a panic for the checks it defers | a panic |
| Escape hatch | unsafe – explicit, scoped, auditable | none for users, the runtime is the boundary |
| Cost | paid at compile time, plus the odd bounds check | 1.5x to 4x at runtime |
It’s ironic how the C/C++ vs Rust debate went from memory safety being overrated and not worth it, to it actually being the selling point of a C/C++ compiler, so much so that the crowd even tolerates a garbage collector! This is good, it means we all now care more about safety, right? Right?!
