Trust boundary specification
I don't think there's much point treating (potential) undefined behaviour and FFI as separate unsafety levels. You can achieve one with the other. I'm also not sure guarding against unsafe-1 is very useful. As I understand it, the most it can do is cause a logic error.
If I understand the RFC correctly, I can call any function marked as unsafe-3 from my package, as long as it is built with the --safe-3. While this nice to prevent packages from unexpectedly using unsafe functions, it doesn't allow for easy auditing of packages.
I find Rust's model very nice. Unsafe operations (such as dereferencing a pointer, or calling an unsafe function) requires an unsafe block around the operation. This way I can easily audit a package and identify all places that call unsafe methods.
A common complaint about Rust's unsafe mechanism is the use of the keyword unsafe in both context, even though they have opposite meanings (as pointing out in the RFC), which leads to confusion.
I would suggest we use something like:
var p : Pointer[Foo ref] = ...
p() // error: Pointer's apply function is marked as unsafe
safe // We explicitly assert that what we're doing here is safe
let x : Foo ref = p() // dereferences p
end
struct Pointer[A]
fun \unsafe\ apply(): this->A => compile_intrinsic
In short, marking a function as unsafe means it has preconditions which are not encoded in the type system and cannot be verified by the compiler. A safe block declares that I have manually verified these preconditions, and order to use a safe block I must pass the --safe option for this package to the compiler.
@plietar Regarding the different safety levels for potential undefined behaviour and FFI, I'll give a concrete example of how this could be useful.
Suppose you have access to raw pointer operations in Pony with unsafe-2, and you make a package implementing some data structure that uses those pointer operations for efficiency. You'll need --safe-2 to build this package. Besides that, you have the net package, which uses the FFI to access the network, and thus requires --safe-3 to build.
In the old Pony playground, which AFAIK wasn't sandboxed in a container/VM, all builds were made with --safe in order to prevent access to files, the network, or other IO via the FFI. Suppose that we implemented this RFC for the old playground. It would be possible to build with --safe-2 and allow the data structure package to build, while still rejecting net. This would be fine because raw pointer operations can only cause your program to behave badly, they won't cause uncontrolled effects on the system.
If we were to have only one safety level for both undefined behaviour and the FFI we'd lose that capability.
Regarding unsafe-1, I think it is necessary since undefined results can provoke abnormal program termination. The most relevant example here is unsafe integer division by zero. Depending on the platform, it can raise a SIGFPE (which will terminate the program), terminate the program without a SIGFPE, return a perfectly defined value, throw an SEH exception, invoke a user-defined function (which can do any of the previous things), and probably some other things on exotic platforms.
It also needs to be separated from unsafe-2 because undefined results can't cause memory corruption (unless some user-defined function for handling division by zero causes memory corruption, but I think we can ignore that extreme edge case since FFI access (or linking the Pony program with some C code) would be needed to setup the function anyway).
I agree that it would be nice to have a way to audit packages for unsafe operations, but I'm not sure that a safe block is the best solution since we'd have to reserve that as a keyword. As an alternative, what do you think of requiring a call-site annotation on unsafe function calls, similar to partial functions? Your example would look like this:
var p : Pointer[Foo ref] = ...
p()
let x : Foo ref = p() \unsafe-2\
struct Pointer[A]
fun \unsafe-2\ apply(): this->A => compile_intrinsic