Top
Best
New

Posted by pjmlp 10/24/2024

Why Safety Profiles Failed(www.circle-lang.org)
237 points | 223 commentspage 2
j16sdiz 10/25/2024|
> A C++ compiler can infer nothing about aliasing from a function declaration.

True. but you don't solely rely on the declaration, do you? lots of power comes from static analysis.

steveklabnik 10/25/2024||
It’s important to only rely on the declaration, for a few reasons. One of the simpler ones is that the body may be in another translation unit.
seanbax 10/25/2024||
You do solely rely on the declaration.

From P3465: "why this is a scalable compile-time solution, because it requires only function-local analysis"

Profiles uses local analysis, as does borrow checking. Whole program analysis is something you don't want to mess with.

MathMonkeyMan 10/25/2024||
Why is `f3` safe?

My thought (which is apparently wrong) is that the `const int& x` refers to memory that might be freed during `vector::push_back`. Then, when we go to construct the new element that is a copy of `x`, it might be invalid to read `x`. No?

Is this related to how a reference-to-const on the stack can extend the lifetime of a temporary to which it's bound? I didn't think that function parameters had this property (i.e. an implicit copy).

seanbax 10/25/2024||
If the vector has to be resized in push_back, the implementation copy-constructs a new object from x. It resizes the buffer. Then it move-constructs the copy into the buffer. The cost is only a move construct per resize... So it's on the order of log2(capacity). Very efficient. But don't get used to it, because there are plenty of other C++ libraries that will break under aliasing.
embe42 10/25/2024||
I think the implementation in C++ does not do realloc on push_back, but allocates new memory, creates the new element and only then deallocates it.

I believe the reason f3 is safe is that the standard says that the iterators/references are invalidated after push_back – so push_back needs to be carefully written to accept aliasing pointers.

I am pretty sure if I were writing my own push_back it would do something like "reserve(size() + 1), copy element into the new place", and it would have different aliasing requirements...

(For me this is a good example of how subtle such things are)

tialaramex 10/26/2024|||
I know this wasn't your main point here but with the C++ std::vector's reserve you must not do this because it will destroy the amortized constant time growth. Rust's Vec can Vec::reserve the extra space because it has both APIs here.

The difference is what these APIs do when we're making small incremental growth choices, Vec::reserve_exact and the std::vector reserve both grow to the exact size asked for, so if we do this for each entry inserted eight times we grow 1, 2, 3, 4, 5, 6, 7, 8 -- we're always paying to grow.

However when Vec::reserve needs to grow it either doubles, or grows to the exact size if bigger than a double. So for the same pattern we grow 1, 2, 4, no growth, 8, no growth, no growth, no growth.

There's no way to fix C++ std::vector without providing a new API, it's just a design goof in this otherwise very normal growable array type. You can somewhat hack around it, but in practice people will just advise you to never bother using reserve except once up front in C++, whereas you will get a performance win in Rust by using reserve to reserve space.

MathMonkeyMan 10/25/2024|||
> allocates new memory, creates the new element and only then deallocates it.

Ah, I'll have to check the standard. Thanks.

zahlman 10/25/2024||
What actually is this circle-lang site, and who runs it? The main page seems to just redirect to example.com, and I don't recognize the name of the author.
steveklabnik 10/25/2024||
Circle is a C++ compiler by Sean Baxter, with various extensions. One of those is an implementation of the Safe C++ proposal I’ve linked downthread.
crote 10/25/2024||
Does the Circle compiler see any real-world use? I keep hearing about it, but it seems like it is a one-person project which hasn't seen significant activity in about a year.

On paper it does sound quite promising, but looking at the Github repo I can't help but shake the feeling that it is more talk than action. It seems to have basically been abandoned before it ever saw any adoption?

pjmlp 10/25/2024|||
At least Circle is real, something that one can download, validate its ideas, what works, what does not.

Most of the profiles being argued for C++ only exist in PDF form, there isn't a single C++ compiler where you can validate those ideas, but trust the authors, the ideas will certainly work once implemented.

steveklabnik 10/25/2024|||
It’s not open source. The GitHub activity is purely for documentation.

I don’t know about real world adoption, nor do I think it’s really about what I’m saying. The proposal was made public a few weeks ago. There hasn’t been much time for real world projects to adopt it, if that were even the goal. The point is that it does exist today, and you can try it out. It’s more than just a design on paper.

catskul2 10/25/2024||
I'm surprised you've not heard of the author (Sean Baxter). He's pretty well known among people who are interested in c++ standards proposals.

He single handedly wrote his own C++ front-end and then proceeded to implement a butt load of extensions which other members of the committees poo-pood as being to hard to implement in the compiler.

Every couple of weeks he implements something new. He's a real joy to follow.

zahlman 10/25/2024||
I'm familiar with C++ and used to use it a fair bit, but I'm way out of date with it. Python has been my primary language almost since I picked it up, nearly 20 years ago.
o11c 10/24/2024||
"No mutable aliases" is a mistake; it prevents many useful programs.

Now that virtual address space is cheap, it's possible to recompile C (or presumably C++) with a fully-safe runtime (requiring annotation only around nasty things like `union sigval`), but this is an ABI break and has nontrivial overhead (note that AddressSanitizers has ~2x overhead and only catches some optimistic cases) unless you mandate additional annotation.

mananaysiempre 10/24/2024||
> AddressSanitizer has ~2x overhead

I’ve got some programs where the ASan overhead is 10× or more. Admittedly, they are somewhat peculiar—one’s an interpreter for a low-level bytecode, the other’s largely a do-nothing benchmark for measuring the overhead of a heartbeat scheduler. The point is, the overhead can vary a lot depending on e.g. how many mallocs your code does.

This does not contradict your point in any way, to be clear. I was just very surprised when I first hit that behaviour expecting ASan’s usual overhead of “not bad, definitely not Valgrind”, so I wanted to share it.

leni536 10/25/2024|||
Don't deploy ASAN builds to production, it's a debugging tool. It might very well introduce attack vectors on its own, it's not designed to be a hardening feature.
stouset 10/24/2024|||
> it prevents many useful programs

Every set of constraints prevents many useful programs. If those useful programs can still be specified in slightly different ways but it prevents many more broken programs, those constraints may be a net improvement on the status quo.

tialaramex 10/24/2024|||
> "No mutable aliases" is a mistake; it prevents many useful programs.

Does it? You didn't list any. It certainly prevents writing a tremendous number of programs which are nonsense.

o11c 10/24/2024|||
The entirety of Rust's `std::cell` is a confession that yes, we really do need mutable aliases. We just pretend they the aliases aren't mutable except for a nanosecond around the actual mutation.
steveklabnik 10/24/2024|||
It’s more than that, they disable the aliasing based optimizations, and provide APIs that restrict how and when you can mutate in order to make sure data races don’t happen.

Controlled mutable aliasing is fine. Uncontrolled is dangerous.

alilleybrinker 10/24/2024||||
Alternatively, Rust's cell types are proof that you usually don't need mutable aliasing, and you can have it at hand when you need it while reaping the benefits of stronger static guarantees without it most of the time.
jerf 10/25/2024||||
And Rc<T> is a "confession" that we still need reference counting semantics. And macros are a "confession" that pure Rust code isn't powerful enough. And "unsafe" is a "confession" that we really do need "unsafe". And so on and so forth with all kinds of features.

Except that's a really harsh and unproductive way to phrase it. The existence of "unsafe" is not a "confession" that safe code is impossible so why should even try.

Progress in safety is still made when an unsafe capability is removed from the normal, common, suggested way to do things and it is moved to something you need to ask for and take responsibility for, and hemmed in by other safe constructs around it.

JoshTriplett 10/24/2024|||
Cells still don't allow simultaneous mutable aliases; they just allow the partitioning of regions of mutable access to occur at runtime rather than compile time.
andrewflnr 10/25/2024|||
OP mentioned std::sort and the rest of std::algorithm as useful functions that use mutable aliasing.
zaphar 10/26/2024||
And other languages implement the same thing just as fast without mutable aliasing.
tialaramex 10/26/2024||
For general purpose sort like std::sort & std::stable_sort the obviously faster (and of course safer) choices are the Rust equivalents [T]::unstable_sort and [T]::sort and their accompanying suite of functions like sort_by_key and select_nth_unstable

There is movement towards adopting the same approach for C++ by some vendors. The biggest problem is that there's a lot of crap C++ out there buried in erroneous custom comparators and so if you change how sorting works even very slightly you blow up lots of fragile nonsense written in C++ and from their point of view you broke their software even though what happened is they wrote nonsense. Next biggest problem is that C++ named their unstable sort "sort" so sometimes people needed a stable sort and they got an unstable sort but in their test suites everything checked out by chance, and with a new algorithm now the test blows up because it's an unstable sort...

morning-coffee 10/25/2024|||
> "No mutable aliases" is a mistake; it prevents many useful programs.

Yes, it prevents many useful programs.

I think it also prevents many many many more useless broken incorrect programs from wreaking havoc or being used as exploit delivery vehicles.

bluGill 10/25/2024||
Writing correct mutable code when there are alias in play is difficult and should be avoided I agree. However sometimes it is the right answer to the problem and so it should be possible for the best programmers to do. However only a minority of code needs that, and when it is needed you need to do a lot of extra review - I wish there was a way to make doing it accidentally impossible on C++.
tsimionescu 10/25/2024|||
How would this fix memory safety issues like std::sort(vec1.begin(), vec2.end()) (where vec1 and vec2 are different vectors, of course)? Or strlen(malloc(100))?
o11c 10/25/2024|||
With a safe runtime, a pointer is really something like a `struct { u32 allocation_base, allocation_offset;}`. (it may be worth doing fancy variable-bit-width math to allow many small allocations but only a few large ones; it's also likely worth it to have a dedicated "leaf" section of memory that is intended not to contain any pointers)

An implementation of `sort` would start with: `assert (begin.allocation_base == end.allocation_base)`. Most likely, this would be implicit when `end - begin` or `begin < end` is called (but not `!=`, which is well-defined between unrelated pointers).

If we ignore the uninitialized data (which is not the same kind of UB, and usually not interesting), the `strlen` loop would assert when, after not encountering a NUL, `s.allocation_offset` exceeds `100` (which is known in the allocation metadata).

badmintonbaseba 10/25/2024|||
Tracking allocations is necessary, but not sufficient.

  struct S {
    int arr1[100];
    std::string s;
    int arr2[100];
  };

  void foo(S& s) {
    //arr1 and arr2 are in the same complete object
    //so they are in the same allocation
    std::sort(std::begin(arr1), std::end(arr2));
  }
To make it sound you really need to track arrays specifically (including implicit single-element ones), not just allocations.

It's surely somewhat feasible, as the constexpr interpreters in compilers do track it, but something like that would probably be way inefficient at runtime.

o11c 10/25/2024||
(if `sizeof(char *)` != `sizeof(int)` it will be detected as an illegal memory access, since at a minimum every word is tagged whether it's a pointer or not. Otherwise ...)

That's really an aliasing problem, not specific to arrays. The tricky part about tagging memory with a full type is that a lot of code, even in standards, relies on aliasing that's supposed to be forbidden.

Still, even if the `sort` isn't detected (due to the user insisting on permissiveness rather than adding annotations), it would still detect any attempt to use `s` afterward.

As for a limited array-specific solution ... I can't think of one that would handle all variants of `sort((int *)&s[0], (int *)&s[1])`. And do we want to forbid `sort(&s.arr[0], (int *)&s.s)`?

tsimionescu 10/25/2024||||
That makes sense, thank you!

I'd bet that there are corners of non-UB vlaid programs that would be sensitive to the fact that pointers are no longer simple numbers, but maybe that's wrong or at least could be worked around.

I would add that to make this memory safe for multi-threaded programs, you also need to implcitly synchronize on fat pointer accesses, to prevent corrupted pointers from forming.

gpderetta 10/25/2024|||
one day we will bring segments and far pointers back.
zozbot234 10/25/2024||
CHERI is pretty much there already
gmueckl 10/25/2024||||
These two examples come from bad library design, not bad language design. The first one was fixed with ranges. The second one would be fixed if C used an explicit string type in it's standard library.
tsimionescu 10/25/2024||
That is besides the point. The claim was about making C++ memory safe by adjusting the runtime, and I was curious how that could work for cases other than use-after-free.
TinkersW 10/25/2024||||
It seems to me that it would handle the sort case fine, you would read/write an invalid page and it would fall over(assuming all allocations had invalid pages at the end of the allocation & no 2 allocations share a page).

The strlen could be made safe by having the malloc return the address 100 bytes before the end of the page. If it did make it to the end of the 100 bytes it would fall over safely. Result would be nonsense of course. Of course if you read bytes < than the returned ptr you would have 4k-100 bytes before it fails on bad page.

NobodyNada 10/25/2024||
> assuming all allocations had invalid pages at the end of the allocation & no 2 allocations share a page

This assumption doesn't hold for real world systems -- most allocations are small, so heap allocators typically support a minimum allocation size of 16 bytes. Requiring each allocation to have its own page would be a 256x memory overhead for these small allocations (assuming 4k pages, and even worse as systems are moving to 16k or 64k page size). Not to mention destroying your TLB.

Also, guard pages only solve the problem if the program tries to access the entire array (like when sorting or reading a string). For other types of out-of-bounds array access where an attacker can control the index accessed, they can just pass in an index high enough to jump over the guard page. You can "fix" this with probing, but at that point just bounds-checking is much simpler and more performant.

randomNumber7 10/25/2024|||
The tinyCC compiler, written by fabrice bellard, has a feature that enables pointer checking and makes the resulting C code safe.
steveklabnik 10/25/2024||
> When a pointer comes from unchecked code, it is assumed to be valid.

It certainly helps, but is not a full solution.

murderfs 10/24/2024|||
virtual address space is cheap, but changing it is massively expensive. If you have to do a TLB shootdown on every free, you're likely going to have worse performance than just using ASan.
o11c 10/24/2024||
Dealing with malloc/free is trivial and cheap - just give every allocated object a couple of reference counts.

The hard part is figuring out which words of memory should be treated as pointers, so that you know when to alter the reference counts.

Most C programs don't rely on all the weird guarantees that C mandates (relying on asm, which is also problematic, is probably more common), but for the ones that do it is quite problematic.

steveklabnik 10/25/2024|||
The borrow checker works irrespective of the heap. Memory safety involves all pointers, not just ones that own a heap allocation.
o11c 10/25/2024||
If we're trying to minimize annotation while maximizing C compatibility, it will be necessary to heap-allocate stack frames. This cost can be mitigated with annotations, once again. In this case, a global "forbid leaks even if unused" flag would cover it.

Static allocations only need full heap compatibility if `dlclose` isn't a nop.

And TLS is the forgotten step-child, but at the lowest level it's ultimately just implemented on normal allocations.

dwattttt 10/25/2024||
> it will be necessary to heap-allocate stack frames.

I sure hope you don't use any stack frames while writing the stack frame allocator.

acbits 10/25/2024||||
https://github.com/acbits/reftrack-plugin

I wrote a compiler extension just for this issue since there wasn't any.

akira2501 10/24/2024|||
> just give every allocated object a couple of reference counts.

Works great with a single thread.

o11c 10/24/2024||
Multi-threaded refcounts aren't actually that hard?

There's overhead (depending on how much you're willing to annotate it and how much you can infer), but the only "hard" thing is the race between accessing a field and and changing the refcount of the object it points to, and [even ignoring alternative CAS approaches] that's easy enough if you control the allocator (do not return memory to the OS until all running threads have checked in).

Note that, in contrast the the common refcount approach, it's probably better to introduce a "this is in use; crash on free" flag to significantly reduce the overhead.

myworkinisgood 10/25/2024||
[flagged]
dang 10/25/2024||
You broke the site guidelines repeatedly in this thread, and posting attacks like this on others will definitely get you banned here if you keep doing it.

If you'd please review https://news.ycombinator.com/newsguidelines.html and stick to the rules when posting here, we'd appreciate it.

mimd 10/25/2024||
I'm confused over lines such as "Profiles have to reject pointer arithmetic, because there’s no static analysis protection against indexing past the end of the allocation." Can't frama-c/etc do that? Additionally, section 2.3 is narrower than what is implied by the words "safe" and "out-of-contract" and is more concerned with what C/C++ call "undefined behavior" requirements than contract correctness. Ie. An integer which is defined to wrap overflows and violates the requirement of the function contract, which I can cause in a safe release build rust.
bjornsing 10/25/2024||
How is it supposed to do that (in the general case)? If I write a C++ program that will index out of bounds iif the Riemann hypothesis is true, then frama-c would have to win the millennium prize to do its job. I bet it can’t.
bluGill 10/25/2024||
Often when I look into questions like this I discover the general case is impossible, but simple hysterics can get 99.999% of the cases and so I can get almost all the benefit even though some rare cases are missed.
zozbot234 10/25/2024||
My own semi-random guess is that "simple hysterics" is indeed how a vast majority (if perhaps not quite 99.999%) of C/C++ devs approaches the code correctness problem - which is why safety mechanisms like the one proposed by OP may in fact be urgently needed. Simple heuristics are likely to be significantly more worthwhile, if appropriately chosen.
bluGill 10/25/2024||
C++ for sure needs better safety mechanisms. And I don't know the exact number of issues simple heuristics can catch.
steveklabnik 10/25/2024||
You cannot cause undefined behavior with integer overflow using + in Rust. That behavior is considered an error, but is well defined.
mimd 10/25/2024||
If it requires the programmer to bear the responsibility for proper usage (eg. must use checked_add not rely on panic), how's that different than the issues with undefined behavior? I'm also concerned with the differing functional behavior between debug and release, and the mistaken impression it could create (eg. code handles overflow in debug fine due to panic but blows up on release as the proper solution is not used). And a resulting propagation of the overflow error to a precondition of an unsafe call that modifies memory.
aw1621107 10/26/2024|||
> If it requires the programmer to bear the responsibility for proper usage (eg. must use checked_add not rely on panic), how's that different than the issues with undefined behavior?

It comes down to the blast radius for a mistake. A mistake involving UB can potentially result in completely arbitrary behavior. A mistake in the safe subset of a language is still a mistake, but the universe of possible consequences is smaller. How much smaller depends on the language in question.

> I'm also concerned with the differing functional behavior between debug and release

IIRC this was a compromise. In an ideal world Rust would always panic on overflow, but the performance consequences were considered to be severe enough to potentially hinder adoption. In addition, overflow checking was not considered memory safety-critical as mandatory bounds checks in safe code would prevent overflow errors from causing memory safety issues in safe Rust.

I believe at the time it was stated that if the cost of overflow checking ever got low enough checking may be (re)enabled on release builds. I'm not sure whether that's still in the cards.

It's not ideal and can lead to problems as you point out when unsafe code is involved (also e.g., CVE-2018-1000810 [0]), but that's the nature of compromises, for better or worse.

[0]: https://groups.google.com/g/rustlang-security-announcements/...

mimd 10/27/2024||
Thanks for the input and links. I'll need to test out the costs of the mitigations.

BTW, I found one of the rust rfc documents helpful for understanding the borrow checker. Do you know if there is a similar rust RFC document for the upcoming polonius borrowchecker, even if it's just a working copy? I'm having trouble finding anything beyond some blog posts.

aw1621107 10/29/2024||
Unfortunately I'm not super-familiar with developments around Polonius, so chances are what I can point you towards are the same things you found when searching. The most relevant bits appear to be the Polonius book [0] linked from the repo [1], but I don't know how up to date the book is or if there are more up-to-date resources. The RFC book [2] doesn't seem to have anything obviously about Polonius either.

[0]: https://rust-lang.github.io/polonius/

[1]: https://github.com/rust-lang/polonius

[2]: https://rust-lang.github.io/rfcs/

steveklabnik 10/26/2024|||
Because defined behavior and undefined behavior operate very differently. One has guaranteed semantics, and the other can do literally anything.
prophesi 10/25/2024||
For those without a dark mode extension:

body {

  background-color: #1f1f1f;

  color: #efefef;
}

.sourceCode {

  background-color: #3f3f3f;

}
account42 10/25/2024|
The assumption here seems to be that the compiler/analyzer is only able to look at one function at a time. This makes no sense. Safety is a whole-program concern and you should analyze the whole program to check it.

If anything as simple as the following needs lifetime annotations then your proposed solution will not be used by anyone:

const int& f4(std::map<int, int>& map, const int& key) { return map[key]; }

simonask 10/25/2024|
Whole-program analysis is not tractable (i.e., not scalable), and Rust has already proven that function signatures are enough, and does actually scale. The analysis can be performed locally at each call site, and doesn't have to recurse into callees.

Your function would look like this in Rust:

    fn f4<'a>(map: &'a Map<i32, i32>, key: &i32) -> &'a i32 { ... }
You don't need much more than a superficial understanding of Rust's lifetime syntax to understand what's going on here, and you have much more information about the function.
jerf 10/25/2024|||
"Whole-program analysis is not tractable (i.e., not scalable),"

The search term for those who'd like to follow up is "Superoptimization", which is one of the perennial ideas that programmers get that will Change the World if it is "just" implemented and "why hasn't anyone else done it I guess maybe they're just stupid", except it turns out to not work in practice. In a nutshell, the complexity classes involved just get too high.

(An interesting question I have is whether a language could be designed from the get-go to work with some useful subset of superoptimizations, but unfortunately, it's really hard to answer such questions when the bare minimum to have a good chance of success is 30 years of fairly specific experience before one even really stands a chance, and by then that's very unlikely to be what that person wants to work on.)

gpderetta 10/25/2024||||
Something I would like to know is how much lifetime annotation you can infer (recursively) from the function implementation itself. Compiler driven, IDE integrated, automatic annotation would be a good tool to have.

Some amount of non-local inference might also be possible for templated C++ code that already lack a proper separate compilation story.

steveklabnik 10/25/2024||
At the limit, the answer is “between zero and completely.” Zero because you may only have access to the prototype and not the body, say if the body is in another translation unit, or completely if a full solution could be found, which is certainly possible for trivial cases.

The reason to not do this isn’t due to impossibility, but for other factors: it’s computationally expensive, I’d you think compile times are already bad, get ready for them to get way worse. Also, changing the body can break code in competently different parts of your program, as changing the body changes the signature and can now invalidate callers.

account42 10/28/2024||
Translation units have long not been a boundary stopping static analyzers or even compiler optimizations with LTO. It's a semantic/namespace concept only.
steveklabnik 10/28/2024||
Sure, you can do some things sometimes. It still means that you need access to everything, which isn't always feasible. And as I said, it's just not the only reason.
account42 10/28/2024|||
> Whole-program analysis is not tractable (i.e., not scalable)

... in the general case. There are many function (sub-)graphs where this is not only tractable but actually trivial. Leave annotations for the tricky cases where the compiler needs help.

> fn f4<'a>(map: &'a Map<i32, i32>, key: &i32) -> &'a i32 { ... }

The problem is not that you can't understand what this means but that it adds too much noise which is both needless busywork when writing the function and distracting when reading the code. There is a reason why many recent C++ additions have been reducing the amount of boilerplate you need to write.

There have been plenty attempts at safty annotations. There is a reason why they have not been adopted by most projects.

simonask 10/28/2024||
I think you might be surprised how rare explicit lifetime annotations actually are in Rust code.

But, to the point: It precisely isn’t “noise”. It’s important information that any competent C++ developer will immediately look for in documentation, or worse, by analyzing the code. It’s not distracting - it’s essential.

Aside: I’m very confused by your assertion that C++ has been reducing boilerplate. It feels like every couple of years there’s another decoration that you need to care about. The only reduction I can think of is defaulted/deleted constructors and assignment ops? But that feels self-inflicted, from a language design perspective.