Safety
SafeC enforces memory safety at compile time through several interlocking analyses. These checks catch common C bugs -- use-after-free, buffer overflows, null dereferences, aliasing violations, leaks of owned resources, data races -- before the program ever runs.
Definite Initialization
Every variable must be assigned before it is used. The compiler performs def-use analysis to enforce this:
int x;
printf("%d\n", x); // ERROR: use of uninitialized variable 'x'
int x;
x = 42;
printf("%d\n", x); // OK: x is initialized
Struct variables are considered initialized at declaration (fields are assigned individually):
Point p;
p.x = 1.0;
p.y = 2.0;
double len = p.x * p.x + p.y * p.y; // OK
Region Escape Analysis
The compiler tracks the region of every reference and ensures it cannot outlive its region:
Stack References Cannot Escape
&stack int bad() {
int local = 10;
return &local; // ERROR: stack reference escapes function
}
References Cannot Escape to Longer-Lived Storage
static &stack int global_ref;
void bad() {
int x = 5;
global_ref = &x; // ERROR: stack ref assigned to static storage
}
Arena References Die on Reset
arena_reset<R>()/arena_destroy<R>() invalidate every outstanding
reference into R, since the memory may be handed out again by a later
new<R>: reading a &arena<R> T reference (or any copy of it) after
the call is a compile error. arena_free_to<R>() (paired with
arena_mark<R>()) is narrower — it only invalidates references allocated
after the checkpoint being freed to. Each mark is tracked separately, so
nested marks are precise, and a reference allocated before any
arena_mark<R>() call for R survives a arena_free_to<R>() call
untouched.
region Pool { capacity: 1024 }
int main() {
&arena<Pool> int p = new<Pool> int;
arena_reset<Pool>();
*p = 42; // ERROR: use of 'p' (&arena<Pool> reference) after
// arena_reset<Pool>(), arena_destroy<Pool>(), or
// arena_free_to<Pool>() invalidated it
return 0;
}
Run in PlaygroundThis is checked by a dataflow analysis that follows the function's
control flow (a reset inside one if/else branch doesn't affect the
other; loops are iterated to a fixpoint, so a reset reaches the top of
the body on the next iteration) and calls (a function that resets R, or
rewinds to a mark it was passed, invalidates the caller's references).
See Memory & Regions
for the full detail, and
Temporal Safety for the
trust boundaries it is sound within. As with every other region/aliasing
check, unsafe {} bypasses this one too.
The same analysis covers &heap T references freed with std::dealloc(p)
— through copies, struct fields, array elements and calls; see
Memory & Regions.
Scope Depth Tracking
The compiler assigns a declScopeDepth to each region and reference. An inner reference cannot be stored in an outer scope:
&stack int ref;
{
int inner = 10;
ref = &inner; // ERROR: inner scope ref escaping to outer
}
Aliasing Rules (Borrow Checker)
SafeC enforces the principle: either one mutable reference, or any number of immutable references, but never both simultaneously.
Single Mutable Reference
int x = 42;
&stack int a = &x; // mutable borrow
&stack int b = &x; // ERROR: x is already mutably borrowed
Multiple Immutable References
int x = 42;
&stack const int a = &x; // immutable borrow
&stack const int b = &x; // OK: multiple immutable borrows allowed
const &stack int a is the same type as &stack const int a: const may go on either side of the region qualifier.
Mutable + Immutable Conflict
int x = 42;
&stack const int r = &x; // immutable borrow
&stack int w = &x; // ERROR: cannot mutably borrow while
// immutable borrow exists
This is what rules out WAR/RAW/WAW hazards
"One mutable XOR many shared" is the same guarantee usually described in
terms of hazards: a write-after-read, read-after-write, or
write-after-write on the same memory from two live references at once.
The "Mutable + Immutable Conflict" example above is a WAR/RAW hazard
(w could write while r still expects to read the old value) rejected
at compile time by the same borrow check that rejects two simultaneous
mutable borrows (a WAW hazard). This holds for ordinary single-threaded
aliasing; see Concurrency for how the same
guarantee extends (and where it stops) across spawn and unsafe.
Scope-Based Tracking
Borrows are tracked per scope. When a scope exits, its borrows are released:
int x = 42;
{
&stack int a = &x; // mutable borrow in inner scope
} // borrow released here
&stack int b = &x; // OK: no conflicting borrow
Nullability Enforcement
Plain references are non-null by default. Nullable references must use the ?& prefix — with a region (?&stack Node) or without one (?&Node, an "outliving reference" with no tracked region at all; see Memory & Regions) — and — unlike languages with flow-sensitive null narrowing — a bare != null comparison does not make a later dereference safe: the compiler performs no flow analysis linking the check to the access. Reading a nullable reference is only sanctioned through match, .is_null(), .default(fallback), or an explicit unsafe block:
void demo(?&stack Node next) {
// *next; // ERROR: cannot dereference nullable ref
// directly, with or without a
// preceding null check
match (next) {
case null: return;
case some(n): {
int val = n.value; // OK: n is bound as the non-null payload
}
}
}
void demo_default(?&stack Node next) {
if (next.is_null()) { // OK: sanctioned null check
return;
}
// still cannot dereference 'next' directly here even after the
// is_null() check above -- use match, or an explicit unsafe block
}
Bounds Safety
The compiler inserts bounds checks for array and slice accesses.
Static Bounds Checking
When the index is a compile-time constant, the check happens at compile time:
int arr[5];
int x = arr[10]; // ERROR: index 10 out of bounds for
// array of size 5
Runtime Bounds Checking
When the index is dynamic, the compiler inserts a runtime check that aborts the program on out-of-bounds access:
int arr[5];
int i = get_index();
int x = arr[i]; // runtime check: if (i >= 5) abort();
Slice Bounds Checking
Slice accesses are always bounds-checked against the slice length:
[]int s = arr[0..3];
int x = s[i]; // runtime check: if (i >= s.len) abort();
Raw Pointers
Outside unsafe, memory is reached only through references (&T, ?&T, with or without a region), and a nullable reference has to be checked with match, .is_null() or .default() first. A raw T* is a C value that safe code can hold but not use:
With a raw pointer p |
Safe code | Needs unsafe |
|---|---|---|
| Store it, pass it to a C function, return it | Yes | |
Compare it (p == q, p == null), widen it to void*, cast it to an integer |
Yes | |
null / (T*)0 |
Yes | |
Make a nullable reference: (?&T)p, or pass it where a ?&T is expected |
Yes | |
Make a non-null reference: (&T)p, or pass it where a &T is expected |
Yes: nothing checks it isn't null | |
Access through it: *p, p[i], p->field |
Yes | |
Arithmetic: p + n, p - n, p += n, p++, p - q |
Yes | |
Make one from an integer: (T*)0x4000 |
Yes | |
Reinterpret it: (char*)p, (T*)voidPtr |
Yes |
The usual way to use a pointer from C in safe code is to turn it into a nullable reference and match it. The region stays unknown (?&T has no region keyword), so the reference can't be stored anywhere that needs a known lifetime:
extern int* find_entry(int key);
int lookup(int key) {
?&int r = (?&int)find_entry(key); // region unknown, may be null
match (r) {
case some(v): return *v; // v is a non-null reference
case null: return -1;
}
return -1;
}
Pointer arithmetic is compiled exactly as in C (scaled by the element size; p - q is a signed 64-bit element distance), but only inside unsafe:
void fill(int* p, int n) {
unsafe {
int* end = p + n;
while (p < end) { *p = 0; p += 1; }
}
}
References have no arithmetic at all: index through a slice or a container instead.
Strict Aliasing
SafeC follows C's strict aliasing rule: a value of one scalar type is never read or written through an lvalue of a different scalar type, except through 8-bit (char, unsigned char, int8_t, bool) lvalues, which may access anything. Signed and unsigned integers of the same width count as one type; an enum or newtype counts as its underlying type; every pointer and reference counts as one type.
The compiler passes this to LLVM as type-based alias information. That lets a loop keep a struct's length and data pointer in registers while it stores elements of another type:
struct Buf { float* data; unsigned long len; };
void scale(&Buf b, float k) {
unsafe {
// The float stores can't change b->len or b->data, so both are
// loaded once and the loop vectorizes.
for (unsigned long i = 0UL; i < b->len; i = i + 1UL) {
b->data[i] = b->data[i] * k;
}
}
}
Safe code can't break the rule: reinterpreting a pointer as another pointer type requires unsafe. Inside unsafe, copy bytes instead of casting when you need a value's bit pattern as another type:
#include <std/stddef.h>
extern void* memcpy(void* dst, const void* src, size_t n); // size_t, not unsigned long: it's 32-bit on wasm32
unsigned int float_bits(float f) {
unsigned int bits = 0U;
unsafe { memcpy((void*)&bits, (const void*)&f, (size_t)4); } // not *(unsigned int*)&f
return bits;
}
Union members and volatile accesses are exempt. Code that does type-pun through pointer casts must be built with --no-strict-aliasing, which turns the alias information off for that compilation.
Reference parameters are not marked noalias. The borrow checker stops two arguments from aliasing, but a function can still reach the same object another way, for example through a global it writes directly, so that promise can't be made.
Ownership and Drop
A type that defines void drop() (the builtin Drop trait) owns a resource, and so does any type holding one. The compiler checks that every such value is dropped or moved exactly once on every path — through return, break, goto, match and defer — and rejects leaks, use after drop or move, double drops and overwriting a live value. It inserts no code: drop() runs only where the source calls it.
void f(int c) {
struct Vec<int> v = Vec::new();
if (c) return; // ERROR: value of Drop type 'Vec<int>' leaks when the function returns
v.drop();
}
See Ownership & Drop.
Thread Safety
Every type is Send (may move to another thread) and/or Sync (may be shared between threads by const &), derived from its structure. spawn, spawn_scoped, channels, std::thread_spawn, Mutex<T> and the async thread pool accept only what is safe to hand over, which rules out data races outside unsafe. See Thread Safety.
The Unsafe Boundary
When the safety rules are too restrictive, unsafe {} blocks allow bypassing them. Inside an unsafe block:
- Bounds checks are suppressed (the
boundsCheckOmitflag is set) - Region escape analysis is relaxed
- Aliasing rules are not enforced
- Raw pointers can be dereferenced (
*p,p[i],p->f), moved with arithmetic, made from integers, reinterpreted as other pointer types, and turned into non-null references ((&T)p) - Null dereferences are not prevented
- Ownership of values reached through raw pointers is not tracked, and
forget(x)may give up a Drop value without dropping it
unsafe {
int *raw = (int*)malloc(10UL * sizeof(int));
raw[0] = 42; // no bounds check
int *alias = raw; // aliasing allowed
free(raw);
}
Unsafe Rules
- Unsafe blocks are explicit -- the programmer opts in to danger
- Unsafe does not propagate -- calling a safe function from unsafe context does not make the callee unsafe
- Minimize unsafe surface area -- keep unsafe blocks as small as possible
Common Unsafe Uses
- FFI calls with non-static region references
- Raw pointer manipulation from C APIs
- Performance-critical inner loops where bounds checks are measurably costly
- Hardware register access in bare-metal code
Summary of Safety Guarantees
By mechanism:
| Safety Check | Compile-Time | Runtime | Suppressed by unsafe |
|---|---|---|---|
| Definite initialization | Yes | -- | No |
| Region escape analysis (stack/static/cross-region) | Yes | -- | Yes |
| Arena-reset/free_to invalidation (dataflow analysis; see above) | Yes | -- | Yes |
Heap use-after-free/double-free invalidation (std::dealloc(p), same analysis; see Memory & Regions) |
Yes | Yes (backstop, wider coverage) | Yes |
| Aliasing / borrow check | Yes | -- | Yes |
| Nullability enforcement | Yes | -- | Yes |
| Static bounds check | Yes | -- | Yes |
| Dynamic bounds check | -- | Yes | Yes |
| Ownership: leaks, use after drop/move, double drop of Drop values | Yes | -- | Yes |
| Thread safety: Send/Sync of what threads receive (Thread Safety) | Yes | -- | Yes (raw pointers only in unsafe) |
Allocator double-free/invalid-free tag check (std::alloc/dealloc, pool.h/slab.h/tlsf.h) |
-- | Yes | N/A (runtime-only) |
By named bug class — this maps the mechanisms above onto the classic C bug taxonomy, since a single mechanism (e.g. the borrow checker) often covers a bug class that goes by several names:
| Bug Class | Compile-Time | Runtime | How |
|---|---|---|---|
| Uninitialized variable read | Yes | -- | Definite initialization, above. |
Null pointer dereference (&T, ?T, ?®ion T) |
Yes | -- | Nullability enforcement, above. Raw C T* is not covered — dereferencing one is only reachable inside unsafe {}, same responsibility model as C. |
| Buffer overflow / overread, constant index | Yes | -- | Static bounds check, above. |
| Buffer overflow / overread, dynamic index | -- | Yes | Runtime bounds check, above — proving an arbitrary runtime-computed index in-bounds ahead of time is undecidable in general, so this is a deliberate runtime backstop, not a gap. |
| Data race: WAR/RAW/WAW on aliased data, single-threaded | Yes | -- | The borrow checker's "one mutable XOR many shared" rule — see the tip above the "Scope-Based Tracking" section. |
| Data race: across threads | Yes | -- | Every type is Send and/or Sync, derived from its structure; spawn only shares static data that is Sync (or synchronizes its own writes), spawn_scoped lends locals until the join, channels and thread_spawn move Send values, and Mutex<T> reaches its value only through a guard. See Thread Safety for what is and isn't covered (deadlocks are not). |
Data race: inside unsafe / raw pointers |
-- | -- | Deliberately unchecked — unsafe is the explicit opt-out for aliasing tracking, same as C. Not a bug in the checker; it's the boundary by design. |
| Use-after-free: stack references | Yes | -- | Region escape analysis, above. |
| Use-after-free: arena references | Yes | -- | Dataflow analysis over each function body, with per-mark arena_free_to tracking and call summaries; see Arena References Die on Reset. |
Use-after-free: heap references (std::dealloc(p)) |
Yes | Yes (backstop) | The same dataflow analysis — follows the pointer through copies, struct fields, array elements and calls, and recognizes allocator-instance .dealloc() methods too; see Memory & Regions for what it does not cover (raw free() and other bodiless extern functions, calls through function pointers, memory reached through raw pointers in unsafe {}). |
| Double free | Yes (std::dealloc(p) or an allocator's .dealloc(p) on a &heap T, or a repeated arena reset/free_to) |
Yes (every other allocator, and as a backstop for the compile-time cases too) | Same mechanism/scope as the heap-UAF row above, plus the allocator tag check below for everything it doesn't cover. |
| Mismatched free (freeing a pointer with the wrong allocator's free function) | -- | Yes | Each allocator (std::alloc/dealloc, and pool.h/slab.h/tlsf.h's allocators) tags its own blocks and checks the tag on free. The compiler doesn't track which allocator produced a given pointer, so a pointer freed through the wrong allocator's free function is caught when that allocator's own tag check rejects it at runtime, not ahead of time. |
| Memory leak: values of a Drop type | Yes | -- | A type with a drop() method (or one holding such a value) must be dropped or moved on every path: forgetting one, using it after drop(), or dropping it twice is a compile error, with no run-time cost. See Ownership & Drop. The standard library's owning types (Vec, String, HashMap, files, sockets, threads, guards, GUI and audio handles, futures) all implement Drop. |
Memory leak: raw allocations, unsafe, cycles |
-- | -- | A bare &heap T from std::alloc that no Drop type owns, memory behind raw pointers, and anything given up with forget() (only allowed in unsafe) aren't tracked. Wrap allocations in a type with drop(), or pair them with defer std::dealloc(...) right next to them — see Defer. |