Zero-cost ownership and memory safety for Zig via comptime typestate.
Add zust to any Zig project in 30 seconds:
cd your-zig-project
./scripts/init-zust.shThen copy-paste into build.zig:
const safe_module = b.addModule("safe", .{
.root_source_file = b.path("lib/safe.zig"),
});
exe.root_module.addImport("safe", safe_module);Replace unsafe types:
| Was | Use Instead |
|---|---|
std.ArrayList(T) |
safe.ArrayList(T) |
std.StringHashMap(T) |
safe.HashMap(safe.String, T) |
std.Thread.Mutex |
safe.Mutex(T) |
allocator.create(T) |
safe.Box(T).init(allocator, default) |
allocator.destroy(ptr) |
defer _ = ptr.deinit() |
var x: i32; (uninit) |
var x = safe.CheckedInt(i32).init(0); |
opt.? |
if (opt) |v| v else return error.Null |
Auto-transpile: ./zig-out/bin/zust-transpile src/main.zig src/main_safe.zig
Full migration guide: MIGRATING.md
Zust ("Zig + Rust") brings Rust's ownership model to Zig — a comptime library that prevents double-free, use-after-free, and mutable aliasing at compile time, with zero runtime overhead. Includes a companion static analyzer with LSP server for real-time IDE diagnostics.
┌─────────────────────────────────────────┐
│ zust = Zig + Rust ownership model │
│ Zero-cost • Compile-time • No GC │
└─────────────────────────────────────────┘
A pure Zig library that encodes ownership state in type parameters. Zero runtime cost. All violations become @compileError.
Box(T, state_tag, imm_count, mut_count)— Owned heap value with typestate transitionsLinkedList(T)— Safe linked list built onBox- Closure API —
withImm/withMutfor zero-cost lexical borrows - Explicit API —
borrowImm/borrowMut/releaseImm/releaseMutfor cross-function borrows
A standalone tool for general-purpose analysis that dog-foods the library.
- Intraprocedural pointer provenance tracking (Box, raw pointers, borrows)
- Pattern detection: flags raw
*T/[]Tusage and suggestssafe.Box - SARIF 2.1.0 output for CI integration
- LSP server mode with
textDocument/publishDiagnostics
Library types: 52 files (50 types + safe.zig)
Tests: 486/486 passing
- Library tests: 348
- SIMD tests: 47
- Fuzz tests: 4
- Analyzer tests: 63
- Transpiler tests: 24
Analyzer detections: 30 bug classes
Transpiler patterns: 22 unsafe → safe rewrites
SIMD speedups: up to 15x on bulk operations
Examples: 2 (HTTP server, JSON parser)
Tools: 3 (transpiler, CLI analyzer, call graph)
LSP features: 6 (diagnostics, completion, go-to-def, hover, code actions, incremental sync)
CI targets: 5 cross-compile + 3 native
const safe = @import("safe");
const Box = safe.Box;
// Create an owned value
const box = try Box(u32).init(allocator, 42);
// Immutable borrow
const b1 = box.borrowImm();
const b2 = b1.borrowImm();
const b1_back = b2.releaseImm();
const box_back = b1_back.releaseImm();
// Deallocate (returns new state, must capture)
const dead = box_back.deinit();
_ = dead;const box = try Box(u32).init(allocator, 42);
var sum: u32 = 0;
box.withImm(&sum, struct {
fn f(ctx: *u32, val: *const u32) void {
ctx.* += val.*;
}
}.f);
const dead = box.deinit();
_ = dead;// Double free
const dead = box.deinit();
const dead2 = dead.deinit(); // @compileError: "double free detected"
// Mutable aliasing
const b1 = box.borrowMut();
const b2 = box.borrowMut(); // @compileError: use of moved value
// Free with active borrows
const borrowed = box.borrowImm();
const dead = box.deinit(); // @compileError: "cannot free while active borrows exist"Every zust type maps directly to a Rust std type. If you know Rust, you already know zust.
// Rust
let b = Box::new(42);
drop(b);// zust
const b = try Box(u32).init(allocator, 42);
const dead = b.deinit();
_ = dead;// Rust
let rc = Rc::new(42);
let rc2 = Rc::clone(&rc);
drop(rc);
assert_eq!(Rc::strong_count(&rc2), 1);// zust
var rc = try Rc(u32).init(allocator, 42);
var rc2 = rc.clone();
rc.drop();
try std.testing.expectEqual(rc2.strongCount(), 1);
rc2.drop();// Rust
let arc = Arc::new(42);
let arc2 = Arc::clone(&arc);// zust
var arc = try Arc(u32).init(allocator, 42);
var arc2 = arc.clone();// Rust
let weak = Arc::downgrade(&arc);
if let Some(arc2) = weak.upgrade() { ... }// zust
var weak = arc.downgrade();
if (weak.upgrade()) |arc2| { ... }// Rust
let mtx = Mutex::new(0);
*mtx.lock().unwrap() = 42;// zust
var mtx = try Mutex(u32).init(allocator, 0);
mtx.lock();
mtx.getMut().* = 42;
mtx.unlock();
// Or with RAII guard:
const guard = mtx.acquire();
guard.getMut().* = 42;
guard.deinit(); // auto-unlock// Rust
let rw = RwLock::new(42);
let read_guard = rw.read().unwrap();// zust
var rw = try RwLock(u32).init(allocator, 42);
rw.readLock();
try std.testing.expectEqual(rw.get().*, 42);
rw.readUnlock();// Rust
let cell = Cell::new(42);
cell.set(100);// zust
var cell = Cell(u32).init(42);
cell.set(100);// Rust
let rc = RefCell::new(42);
let b = rc.borrow();
let b2 = rc.borrow_mut(); // panic at runtime// zust
var rc = RefCell(u32).init(42);
const b = rc.borrow();
// const b2 = rc.borrowMut(); // panic at runtime
b.deinit();// Rust
let uc = UnsafeCell::new(42);
let ptr = uc.get();// zust
var uc = UnsafeCell(u32).init(42);
const ptr = uc.getMut();// Rust
let md = ManuallyDrop::new(Box::new(42));
ManuallyDrop::drop(&mut md);// zust
var md = ManuallyDrop(u32).init(42);
md.drop();// Rust
let mut mu = MaybeUninit::<u32>::uninit();
mu.write(42);
let val = unsafe { mu.assume_init() };// zust
var mu = MaybeUninit(u32).init();
mu.write(42);
const val = mu.assumeInit();// Rust
let pin = Box::pin(42);
*pin.as_mut().get_mut() = 100;// zust
var pin = try Pin(u32).init(try Box(u32).init(allocator, 42));
pin.getMut().* = 100;
const dead = pin.deinit();
_ = dead;// Rust
struct MyPtr<T> { ptr: *mut u8, _phantom: PhantomData<T> }// zust
const PhantomU32 = PhantomData(u32);
var marker = PhantomU32.init();// Rust
static CELL: OnceLock<u32> = OnceLock::new();
CELL.set(42).unwrap();// zust
var cell = OnceCell(u32).init();
try cell.set(42);// Rust
let lazy: LazyCell<u32> = LazyCell::new(|| 42);
*lazy.borrow_mut() = 100;// zust
var lazy = LazyCell(u32).init(struct {
fn init() u32 { return 42; }
}.init);
_ = lazy.getMut().* = 100;// Rust
let mut v = Vec::new();
v.push(10);
v.push(20);// zust
var list = ArrayList(u32).init(allocator);
defer list.deinit();
try list.append(try Box(u32).init(allocator, 10));
try list.append(try Box(u32).init(allocator, 20));// Rust
let mut dq = VecDeque::new();
dq.push_back(10);
dq.push_front(5);// zust
var dq = try VecDeque(u32).init(allocator);
defer dq.deinit();
try dq.pushBack(try Box(u32).init(allocator, 10));
try dq.pushFront(try Box(u32).init(allocator, 5));// Rust
let mut list = LinkedList::new();
list.push_front(10);// zust
var list = LinkedList(u32).init(allocator);
defer list.deinit();
try list.push(10);// Rust
let mut map = HashMap::new();
map.insert("key", 42);// zust
var map = HashMap(u32).init(allocator);
defer map.deinit();
try map.put("key", try Box(u32).init(allocator, 42));// Rust
let mut map = BTreeMap::new();
map.insert(1, 42);// zust
var map = BTreeMap(u32).init(allocator);
defer map.deinit();
try map.put(1, try Box(u32).init(allocator, 42));// Rust
let mut set = HashSet::new();
set.insert(42);// zust
var set = HashSet.init(allocator);
defer set.deinit();
try set.insert(42);// Rust
let mut heap = BinaryHeap::new();
heap.push(42);
let max = heap.pop().unwrap();// zust
var heap = try BinaryHeap(u32).init(allocator, struct {
fn cmp(_: void, a: *const u32, b: *const u32) bool { return a.* > b.*; }
}.cmp);
try heap.push(try Box(u32).init(allocator, 42));
const max = heap.pop().?;// Rust
let mut s = String::new();
s.push_str("hello");// zust
var s = String.init(allocator);
defer s.deinit();
try s.append("hello");// Rust
let cow: Cow<'_, str> = Cow::Borrowed("hello");
let owned = cow.into_owned();// zust
var cow = Cow([]const u8).initBorrowed("hello");
var owned = try cow.toOwned(allocator);// Rust
let arr = [10, 20, 30];
let s: &[u32] = &arr;// zust
const arr = [_]u32{ 10, 20, 30 };
const s = Slice(u32).fromStack(&arr);
s.release();// Rust (non-lexical lifetimes via drop)
{
let b = Box::new(42);
// b dropped here
}// zust
{
const box = try Box(u32).init(allocator, 42);
const borrowed = ScopeImm(u32).borrow(box);
_ = borrowed.scope.release();
const dead = box.deinit();
_ = dead;
}// zust
var abox = try AsyncBox(u32).init(allocator, 42);
const box = abox.take().?;
const dead = box.deinit();
_ = dead;// Rust
let (tx, rx) = mpsc::channel::<u32>();
tx.send(42).unwrap();
let val = rx.recv().unwrap();// zust
var ch = try Channel(u32).init(allocator, 4);
defer ch.deinit();
try ch.send(42);
const val = ch.recv().?;// Rust
let (tx, rx) = oneshot::channel::<u32>();
tx.send(42).unwrap();// zust
var os = Oneshot(u32).init();
try os.send(42);
const val = os.recv().?;zust provides iterator adapters and consumers inspired by Rust's Iterator trait. Unlike Rust's chained .map().filter().collect(), Zig's comptime generics require explicit type instantiation. All adapters are zero-cost — they compile down to simple loops.
Transform each element.
// Rust
let doubled: Vec<i32> = vec![1, 2, 3]
.iter()
.map(|x| x * 2)
.collect();// zust
const Iterators = safe.Iterators;
var range = Iterators.RangeIter(u32).init(1, 4);
var mapped = Iterators.MapIter(
Iterators.RangeIter(u32), // source iterator type
u32, // context type
u32, // input item type
u32 // output item type
).init(range, 2, struct {
fn f(ctx: u32, val: u32) u32 {
return val * ctx;
}
}.f);
try std.testing.expectEqual(mapped.next().?, 2); // 1 * 2
try std.testing.expectEqual(mapped.next().?, 4); // 2 * 2
try std.testing.expectEqual(mapped.next().?, 6); // 3 * 2
try std.testing.expect(mapped.next() == null);Keep only elements matching a predicate.
// Rust
let evens: Vec<i32> = vec![0, 1, 2, 3, 4, 5]
.into_iter()
.filter(|x| x % 2 == 0)
.collect();// zust
var range = Iterators.RangeIter(u32).init(0, 6);
var filtered = Iterators.FilterIter(
Iterators.RangeIter(u32),
void, // no context needed
u32
).init(range, {}, struct {
fn f(_: void, val: *const u32) bool {
return val.* % 2 == 0;
}
}.f);
try std.testing.expectEqual(filtered.next().?, 0);
try std.testing.expectEqual(filtered.next().?, 2);
try std.testing.expectEqual(filtered.next().?, 4);
try std.testing.expect(filtered.next() == null);Yield (index, value) pairs.
// Rust
for (i, val) in vec!["a", "b", "c"].iter().enumerate() {
println!("{}: {}", i, val);
}// zust
const items = [_][]const u8{ "a", "b", "c" };
var slice_it = Iterators.SliceIter([]const u8).init(&items);
var enumerated = Iterators.EnumerateIter(
Iterators.SliceIter([]const u8),
[]const u8
).init(slice_it);
const first = enumerated.next().?;
try std.testing.expectEqual(first.index, 0);
try std.testing.expect(std.mem.eql(u8, first.value, "a"));
const second = enumerated.next().?;
try std.testing.expectEqual(second.index, 1);
try std.testing.expect(std.mem.eql(u8, second.value, "b"));Take only the first N elements.
// Rust
let first_3: Vec<i32> = vec![1, 2, 3, 4, 5]
.into_iter()
.take(3)
.collect();// zust
var range = Iterators.RangeIter(u32).init(0, 100);
var taken = Iterators.TakeIter(Iterators.RangeIter(u32), u32).init(range, 3);
try std.testing.expectEqual(taken.next().?, 0);
try std.testing.expectEqual(taken.next().?, 1);
try std.testing.expectEqual(taken.next().?, 2);
try std.testing.expect(taken.next() == null);Skip the first N elements.
// Rust
let rest: Vec<i32> = vec![1, 2, 3, 4, 5]
.into_iter()
.skip(2)
.collect();// zust
var range = Iterators.RangeIter(u32).init(0, 6);
var skipped = Iterators.SkipIter(Iterators.RangeIter(u32), u32).init(range, 3);
try std.testing.expectEqual(skipped.next().?, 3);
try std.testing.expectEqual(skipped.next().?, 4);
try std.testing.expectEqual(skipped.next().?, 5);
try std.testing.expect(skipped.next() == null);Concatenate two iterators.
// Rust
let chained: Vec<i32> = vec![1, 2, 3]
.into_iter()
.chain(vec![10, 11, 12])
.collect();// zust
const first = Iterators.RangeIter(u32).init(0, 3);
const second = Iterators.RangeIter(u32).init(10, 13);
var chained = Iterators.ChainIter(
Iterators.RangeIter(u32),
Iterators.RangeIter(u32),
u32
).init(first, second);
try std.testing.expectEqual(chained.next().?, 0);
try std.testing.expectEqual(chained.next().?, 1);
try std.testing.expectEqual(chained.next().?, 2);
try std.testing.expectEqual(chained.next().?, 10);
try std.testing.expectEqual(chained.next().?, 11);
try std.testing.expectEqual(chained.next().?, 12);
try std.testing.expect(chained.next() == null);Pair elements from two iterators.
// Rust
let pairs: Vec<(i32, &str)> = vec![1, 2, 3]
.into_iter()
.zip(vec!["a", "b", "c"])
.collect();// zust
const nums = Iterators.RangeIter(u32).init(1, 4);
const letters = Iterators.SliceIter(u8).init("abc");
var zipped = Iterators.ZipIter(
Iterators.RangeIter(u32),
Iterators.SliceIter(u8),
u32,
u8
).init(nums, letters);
const first = zipped.next().?;
try std.testing.expectEqual(first.first, 1);
try std.testing.expectEqual(first.second, 'a');
const second = zipped.next().?;
try std.testing.expectEqual(second.first, 2);
try std.testing.expectEqual(second.second, 'b');Reduce to a single value.
// Rust
let sum: i32 = vec![1, 2, 3, 4].iter().fold(0, |acc, x| acc + x);
let product: i32 = vec![1, 2, 3, 4].iter().fold(1, |acc, x| acc * x);// zust
var range = Iterators.RangeIter(u32).init(1, 5);
const total = Iterators.fold(
Iterators.RangeIter(u32), u32, u32,
&range,
0, // initial accumulator
{}, // no context
struct { // reducer function
fn f(_: void, acc: u32, val: u32) u32 {
return acc + val;
}
}.f
);
try std.testing.expectEqual(total, 10); // 1+2+3+4Gather all elements into a std.ArrayList.
// Rust
let collected: Vec<i32> = (0..3).collect();// zust
var range = Iterators.RangeIter(u32).init(0, 3);
var list = try Iterators.collectArrayList(
Iterators.RangeIter(u32), u32,
&range,
std.testing.allocator
);
defer list.deinit(std.testing.allocator);
try std.testing.expectEqual(list.items.len, 3);
try std.testing.expectEqual(list.items[0], 0);
try std.testing.expectEqual(list.items[1], 1);
try std.testing.expectEqual(list.items[2], 2);Search and test predicates.
// Rust
let found = vec![1, 2, 3, 4].iter().find(|x| x > 2);
let idx = vec![10, 20, 30].iter().position(|x| x == 20);
let all_positive = vec![1, 2, 3].iter().all(|x| x > 0);
let any_big = vec![1, 2, 3].iter().any(|x| x > 10);// zust
var range = Iterators.RangeIter(u32).init(1, 6);
const found = Iterators.find(
Iterators.RangeIter(u32), u32, &range, {},
struct { fn f(_: void, val: *const u32) bool { return val.* > 2; } }.f
);
try std.testing.expectEqual(found.?, 3);
var range2 = Iterators.RangeIter(u32).init(10, 16);
const idx = Iterators.position(
Iterators.RangeIter(u32), u32, &range2, {},
struct { fn f(_: void, val: *const u32) bool { return val.* == 13; } }.f
);
try std.testing.expectEqual(idx.?, 3);
var range3 = Iterators.RangeIter(u32).init(1, 4);
const all_positive = Iterators.all(
Iterators.RangeIter(u32), u32, &range3, {},
struct { fn f(_: void, val: *const u32) bool { return val.* > 0; } }.f
);
try std.testing.expect(all_positive);Numeric aggregation.
// Rust
let total: i32 = vec![1, 2, 3, 4].iter().sum();
let smallest = vec![5, 2, 8, 1].iter().min();
let largest = vec![5, 2, 8, 1].iter().max();// zust
var range = Iterators.RangeIter(u32).init(1, 5);
const total = Iterators.sum(Iterators.RangeIter(u32), u32, &range);
try std.testing.expectEqual(total, 10);
var vals = [_]u32{ 5, 2, 8, 1 };
var slice_it = Iterators.SliceIter(u32).init(&vals);
const smallest = Iterators.min(
Iterators.SliceIter(u32), u32, &slice_it, {},
struct { fn f(_: void, a: *const u32, b: *const u32) bool { return a.* < b.*; } }.f
);
try std.testing.expectEqual(smallest.?, 1);// WITHOUT zust: Iterator invalidation
fn bad_iterator() void {
var list = std.ArrayList(u32).init(std.testing.allocator);
defer list.deinit();
try list.append(1);
try list.append(2);
var it = list.iterator();
_ = list.pop(); // 💥 INVALIDATES iterator
const val = it.next(); // UB: iterator points to freed/relocated memory
_ = val;
}// WITH zust: Consuming iterators prevent invalidation
fn safe_iterator() void {
var list = ArrayList(u32).init(std.testing.allocator);
defer list.deinit();
try list.append(try Box(u32).init(std.testing.allocator, 1));
try list.append(try Box(u32).init(std.testing.allocator, 2));
var it = list.iterator();
const first = it.next(); // ✅ Pops from list, takes ownership
if (first) |box| {
const dead = box.deinit();
_ = dead;
}
// list is now empty; no invalidation possible
}// Rust
let old = std::mem::replace(&mut x, 100);
std::mem::swap(&mut a, &mut b);
let val = std::mem::take(&mut x); // requires Default// zust
const old = safe.replace(u32, &x, 100);
safe.swap(u32, &a, &b);
const val = safe.take(u32, &x);// Rust
todo!("implement this");
unreachable!();// zust
safe.todo("implement this");
safe.unreachable_code();For every zust type, here's what goes wrong without it — and how zust prevents the bug.
// WITHOUT zust: Double-free crash
fn bad_double_free() void {
const ptr = std.testing.allocator.create(u32) catch return;
ptr.* = 42;
std.testing.allocator.destroy(ptr);
std.testing.allocator.destroy(ptr); // 💥 CRASH: double-free
}// WITH zust: Compile-time prevention
fn safe_with_box() void {
const box = try Box(u32).init(std.testing.allocator, 42);
const dead = box.deinit();
// const dead2 = dead.deinit(); // ❌ @compileError: "double free detected"
_ = dead;
}// WITHOUT zust: Use-after-free bug
fn bad_uaf() void {
var ptr = std.testing.allocator.create(u32) catch return;
ptr.* = 42;
std.testing.allocator.destroy(ptr);
std.debug.print("{d}\n", .{ptr.*}); // 💥 UAF: reading freed memory
}// WITH zust: Compile-time prevention
fn safe_no_uaf() void {
const box = try Box(u32).init(std.testing.allocator, 42);
const dead = box.deinit();
// dead.ptr.* = 100; // ❌ @compileError: use of moved value
_ = dead;
}// WITHOUT zust: Dangling reference after drop
fn bad_rc() void {
const ptr = std.testing.allocator.create(u32) catch return;
ptr.* = 42;
var ptr2 = ptr; // "shared" ownership (not really)
std.testing.allocator.destroy(ptr); // first owner frees
ptr2.* = 100; // 💥 UAF: ptr2 is dangling
}// WITH zust: Reference counting prevents premature drop
fn safe_rc() void {
var rc = try Rc(u32).init(std.testing.allocator, 42);
var rc2 = rc.clone(); // strong count = 2
rc.drop(); // strong count = 1, NOT freed
rc2.getMut().* = 100; // ✅ Safe: rc2 still owns it
rc2.drop(); // strong count = 0, now freed
}// WITHOUT zust: Data race
var global_counter: u32 = 0;
fn bad_race() void {
global_counter += 1; // 💥 DATA RACE if called from multiple threads
}// WITH zust: Compile-time error if accessed without lock
fn safe_mutex() void {
var mtx = try Mutex(u32).init(std.testing.allocator, 0);
defer mtx.deinit();
// mtx.getMut().* = 100; // ❌ Runtime: must call lock() first
mtx.lock();
mtx.getMut().* += 1; // ✅ Safe: lock held
mtx.unlock(); // Explicit unlock
}// WITHOUT zust: Accidental deadlock
fn bad_deadlock() void {
var mtx: std.Thread.Mutex = .{};
mtx.lock();
// ... forget to unlock ...
mtx.lock(); // 💥 DEADLOCK: already locked
}// WITH zust: Analyzer detects double-lock
fn safe_no_deadlock() void {
var mtx = try Mutex(u32).init(std.testing.allocator, 0);
defer mtx.deinit();
mtx.lock();
// mtx.lock(); // ❌ Analyzer: "locking already-locked Mutex"
mtx.unlock();
}// WITHOUT zust: Mutable aliasing
fn bad_aliasing() void {
var x: u32 = 42;
const p1 = &x;
const p2 = &x;
p1.* = 100;
p2.* = 200; // 💥 UNDEFINED BEHAVIOR: two mutable pointers
}// WITH zust: Runtime borrow checking
fn safe_refcell() void {
var rc = RefCell(u32).init(42);
const b1 = rc.borrowMut();
// const b2 = rc.borrowMut(); // ❌ Panic: already borrowed mutably
b1.deinit();
}// WITHOUT zust: Race condition on initialization
var initialized: bool = false;
var value: u32 = 0;
fn bad_init() void {
if (!initialized) {
value = 42; // 💥 RACE: two threads could both enter here
initialized = true;
}
}// WITH zust: Panic on double set
fn safe_once() void {
var cell = OnceCell(u32).init();
try cell.set(42); // ✅ First set succeeds
// try cell.set(100); // ❌ Panic: "AlreadyInitialized"
const v = cell.get().?.*; // ✅ Safe: guaranteed initialized
_ = v;
}// WITHOUT zust: Memory leak
fn bad_leak() void {
const ptr = std.testing.allocator.create(u32) catch return;
ptr.* = 42;
// forget to free... 💥 LEAK
}// WITH zust: Analyzer detects missing drop
fn safe_manual_drop() void {
var md = ManuallyDrop(u32).init(42);
// md.drop(); // ❌ Analyzer at end-of-function: "ManuallyDrop not dropped"
md.drop(); // ✅ Explicit drop required
}// WITHOUT zust: Reading uninitialized memory
fn bad_uninit() void {
var x: u32 = undefined;
std.debug.print("{d}\n", .{x}); // 💥 UB: reading undefined value
}// WITH zust: Must write before read
fn safe_uninit() void {
var mu = MaybeUninit(u32).init();
// const val = mu.assumeInit(); // ❌ Analyzer: "uninitialized MaybeUninit"
mu.write(42);
const val = mu.assumeInit(); // ✅ Safe: initialized
_ = val;
}// WITHOUT zust: Self-referential struct breaks on move
const SelfRef = struct {
data: [4]u8,
ptr: []u8, // points to data field
};
fn bad_move() void {
var s = SelfRef{ .data = .{1, 2, 3, 4}, .ptr = &s.data };
var s2 = s; // 💥 ptr now points to s.data which was moved!
s2.ptr[0] = 100; // UB: dangling self-reference
}// WITH zust: Pin prevents moving
fn safe_pin() void {
const box = try Box(SelfRef).init(std.testing.allocator, undefined);
var pin = Pin(SelfRef).init(box); // Pinned on heap
pin.getMut().ptr = &pin.getMut().data;
// var moved = pin; // ❌ Analyzer: "Pin value moved"
const dead = pin.deinit();
_ = dead;
}// WITHOUT zust: Iterator invalidation
fn bad_iter() void {
var map = std.StringHashMap(u32).init(std.testing.allocator);
defer map.deinit();
try map.put("a", 1);
var it = map.iterator();
map.remove("a"); // 💥 INVALIDATES iterator
const entry = it.next().?; // UB: iterator points to freed memory
_ = entry;
}// WITH zust: Ownership-aware iterator
fn safe_hashmap() void {
var map = HashMap(u32).init(std.testing.allocator);
defer map.deinit();
try map.put("a", try Box(u32).init(std.testing.allocator, 1));
var entry = map.get("a"); // ✅ Ownership transfer: removed from map
if (entry) |box| {
const dead = box.deinit(); // ✅ Properly freed
_ = dead;
}
}// WITHOUT zust: Use-after-close
fn bad_channel() void {
const allocator = std.testing.allocator;
const cap: usize = 4;
var buf = try allocator.alloc(u32, cap);
defer allocator.free(buf);
var closed = false;
// ...close channel...
closed = true;
if (!closed) {
buf[0] = 42; // But what if another thread closes it here?
}
}// WITH zust: Runtime close tracking
fn safe_channel() void {
var ch = try Channel(u32).init(std.testing.allocator, 4);
defer ch.deinit();
ch.close();
// ch.send(42); // ❌ Panic: "ChannelClosed"
}// WITHOUT zust: Overwriting a oneshot value
fn bad_oneshot() void {
var value: ?u32 = null;
value = 42;
value = 100; // 💥 Silently overwrites previous value
_ = value;
}// WITH zust: Panic on double-send
fn safe_oneshot() void {
var os = Oneshot(u32).init();
try os.send(42); // ✅ First send succeeds
// try os.send(100); // ❌ Panic: "AlreadySent"
}// WITHOUT zust: Buffer overflow
fn bad_string() void {
var buf: [5]u8 = .{0} ** 5;
const msg = "hello world";
@memcpy(&buf, msg); // 💥 BUFFER OVERFLOW: writes past end of buf
}// WITH zust: Growable buffer with bounds checking
fn safe_string() void {
var s = String.init(std.testing.allocator);
defer s.deinit();
try s.append("hello world"); // ✅ Grows automatically
try std.testing.expectEqual(s.len(), 11);
}// WITHOUT zust: Out-of-bounds access
fn bad_slice() void {
const arr = [_]u32{10, 20, 30};
const s = arr[0..5]; // 💥 OOB in release mode, panic in debug
_ = s;
}// WITH zust: Bounds-checked access
fn safe_slice() void {
const arr = [_]u32{10, 20, 30};
const s = Slice(u32).fromStack(&arr);
_ = s.get(5); // ✅ Returns null (not UB)
s.release();
}| zust Type | Bug Without zust | How zust Prevents It |
|---|---|---|
Box |
Double-free, use-after-free | @compileError on misuse |
Rc/Arc |
Premature drop, UAF | Reference counting |
Mutex |
Data races | Must acquire lock |
RwLock |
Writer starvation | Writer-preference lock |
RefCell |
Mutable aliasing | Runtime borrow checking |
OnceCell |
Race on initialization | AlreadyInitialized panic |
ManuallyDrop |
Memory leak | Analyzer: "not dropped" |
MaybeUninit |
Reading undefined | NotInitialized error |
Pin |
Broken self-references | InvalidMove error |
HashMap |
Iterator invalidation | Ownership-transfer API |
Channel |
Send-after-close | ChannelClosed panic |
Oneshot |
Overwriting value | AlreadySent panic |
String |
Buffer overflow | Automatic growth |
Slice |
Out-of-bounds | Returns null |
| Bug Class | Detection | Example |
|---|---|---|
| Double-free | ✅ Error | dead.deinit() on already-freed Box |
| Use-after-free | ✅ Error | raw.* = 100 after box.deinit() |
| Pointer escape | ✅ Error | global_ptr = box.unsafePtr() then box.deinit() |
| Dangling argument | ✅ Error | Passing raw pointer to function after deallocation |
| Raw allocation | ✅ Warning | allocator.create(T) → suggest Box(T).init() |
| Raw deallocation | ✅ Warning | allocator.destroy(ptr) → suggest Box.deinit() |
| Raw pointer types | ✅ Warning | fn foo() *u32 → suggest returning Box |
| Raw pointer deref | ✅ Warning | ptr.* = 42 → suggest .withImm()/.withMut() |
The analyzer also flags patterns where you should be using safe.Box but aren't:
// Analyzer will flag this:
var raw: *u32 = undefined;
raw.* = 42;
allocator.destroy(raw);
// And suggest:
var box = try Box(u32).init(allocator, 42);
box.withImm({}, struct { fn f(_: void, val: *const u32) void {
// use val
}}.f);
const dead = box.deinit();cd zust
zig build test-all# Run analyzer on the project's own source files (dog-food check)
cd zust
zig build analyze
# With options
zig build analyze -Dstrictness=high -Dsarif=truecd zust/analyzer
zig build run -- ../tests/example.zig
zig build run -- ../tests/example.zig --sarif
zig build run -- ../tests/example.zig --strictness=highcd zust
zig build wasmThis produces zig-out/bin/zust-analyzer.wasm, a WASI-compatible WebAssembly module that runs the LSP server over stdin/stdout. Useful for:
- VS Code web (github.dev / vscode.dev)
- Browser-based IDEs
- Sandboxed environments
cd zust/analyzer
zig build run -- --lspThe LSP server speaks JSON-RPC 2.0 over stdin/stdout. It supports:
initialize/initializedtextDocument/didOpen→ runs analysis →textDocument/publishDiagnosticstextDocument/didChange→ re-runs analysis → publishes updated diagnosticstextDocument/didCloseshutdown/exit
Connect it to your editor by configuring the language server command:
// VS Code settings.json example
{
"zig.languageServer": {
"command": "/path/to/zust-analyze",
"args": ["--lsp"]
}
}A reference VS Code extension is provided in vscode-extension/:
cd vscode-extension
npm install
npm run compile
# Press F5 in VS Code to launch the Extension Development HostThe extension auto-starts the zust analyzer in --lsp mode for all .zig files. Configure it via VS Code settings:
| Setting | Description | Default |
|---|---|---|
zust.enable |
Enable/disable analysis | true |
zust.serverPath |
Path to analyzer binary | zust-analyzer |
zust.strictness |
Analysis strictness | Medium |
zust includes first-class integration with OpenCode via MCP (Model Context Protocol) and custom commands.
-
Build the analyzer:
zig build
-
OpenCode will auto-detect the project
opencode.jsonwhen you work in the zust directory.
| Command | Description |
|---|---|
/zust-check |
Run zig build analyze and report diagnostics |
/zust-test |
Run zig build test-all and report results |
The zust MCP server exposes these tools that OpenCode can call automatically:
-
zust_analyze_file— Analyze a.zigfile for memory safety issues{ "file_path": "src/main.zig", "strictness": "high" } -
zust_analyze_project— Run full project analysis (zig build analyze) -
zust_check_patterns— Check a code snippet for unsafe patterns{ "code": "var ptr = allocator.create(u32); ..." }
When you open the zust project in OpenCode:
- The
opencode.jsonregisters the zust MCP server - The MCP server wraps
zust-analyzeas an MCP tool provider - When you edit Zig files, OpenCode can call
zust_analyze_fileto check for issues - The
/zust-checkcommand runs the full analyzer on demand
The project-level opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"command": {
"zust-check": {
"description": "Run zust memory safety analyzer",
"prompt": "Run `zig build analyze` and report diagnostics"
}
},
"mcp": {
"zust-analyzer": {
"type": "local",
"command": ["node", ".opencode/mcp/zust-mcp-server.js"],
"enabled": true
}
}
}# Test the MCP server directly
cd zust
node .opencode/mcp/zust-mcp-server.js
# Then send MCP JSON-RPC messages via stdinThe analyzer eats its own dog food:
-
Analyzer tracks pointers with
safe.LinkedList- Each
PointerValuenode is asafe.Boxallocation - The list uses
borrowImm/borrowMutfor traversal
- Each
-
Analyzer manages AST lifecycle with
safe.Box- Parsed
std.zig.Astlives in aBox(std.zig.Ast) - Explicit
.deinit()ensures single-owner cleanup
- Parsed
-
LSP Server uses
safe.Boxfor the analyzeranalyzer: Box(Analysis.Analyzer)unsafePtr()to borrow and call methods- Explicit deinit order in
Server.deinit()
-
LSP Server uses
safe.LinkedListfor diagnostic historydiagnostic_history: LinkedList(Diagnostic.Diagnostic)- Tracks all published diagnostics for debugging
| Bug Class | Detection | Mechanism |
|---|---|---|
| Double-free | ✅ @compileError |
Typestate: deinit() returns Freed, can't deinit Freed |
| Use-after-move | ✅ @compileError |
Moved values have different type, can't access old binding |
| Mutable aliasing | ✅ @compileError |
borrowMut() changes type to prevent second borrow |
| Mixed borrow | ✅ @compileError |
imm_count/mut_count in type parameters |
| Free with active borrows | ✅ @compileError |
deinit() requires (0, 0, 0) state |
| Bug Class | Detection | Mechanism |
|---|---|---|
| Memory Errors | ||
| Use-after-free (raw ptr) | ✅ Error | Provenance: track raw pointers derived from Boxes |
| Double-free | ✅ Error | Track variable is_live state |
| Pointer escape | ✅ Error | Detect assignments to globals/fields from Boxes |
| Memory leak | ✅ Warning | Live zust types at scope/function exit |
| Resource leak (error paths) | ✅ Warning | Missing errdefer cleanup |
| Must-use return value | ✅ Warning | Discarded zust constructor result |
| Ownership Violations | ||
| Use-after-move | ✅ Error | Track moved variable state |
| Mutable aliasing | ✅ Error | Detect simultaneous mutable borrows |
| Invalid move | ✅ Error | Moving non-Copy type without ownership |
| Concurrency | ||
| Data race | ✅ Error | Shared mutable state across threads |
| Deadlock | ✅ Error | Circular lock dependencies |
| Lock order violation | ✅ Error | Out-of-order lock acquisition |
| Recursive lock | ✅ Error | Same thread re-locks mutex |
| Iterator invalidation | ✅ Error | Modifying collection during iteration |
| Initialization | ||
| Uninitialized memory | ✅ Error | Read before write |
| Not initialized | ✅ Error | MaybeUninit/OnceCell used before init |
| Already initialized | ✅ Error | Double-init of OnceCell/LazyStatic |
| Null dereference | ✅ Error | opt.? without null check |
| Bounds & Arithmetic | ||
| Buffer overflow | ✅ Error | Compile-time array index out of bounds |
| Unchecked index | ✅ Warning | Variable index without if (i < len) guard |
| Division by zero | ✅ Error | Literal zero divisor |
| Shift overflow | ✅ Error | Shift amount >= bit width |
| Unsafe Patterns | ||
| Raw pointer arithmetic | ✅ Error | ptr + n on raw pointers |
| PtrCast without align | ✅ Warning | @ptrCast without @alignCast |
| Raw allocation | ✅ Warning | allocator.create(T) → suggest Box |
| Raw deallocation | ✅ Warning | allocator.destroy(ptr) → suggest Box.deinit() |
| Raw pointer types | ✅ Warning | *T in vars/params/returns → suggest Box |
| Raw pointer dereference | ✅ Warning | ptr.* → suggest .withImm()/.withMut() |
| Zust-Specific | ||
| ManuallyDrop not dropped | ✅ Warning | Missing .drop() before scope exit |
| OnceCell double-set | ✅ Error | Second .set() call |
| Mutex not unlocked | ✅ Warning | MutexGuard not deinit'd |
| Pin moved | ✅ Error | Moving a pinned value |
| Channel send-after-close | ✅ Error | Send to closed channel |
| Already sent | ✅ Error | Second send on oneshot channel |
These are known limitations. Contributions welcome.
| Gap | Why | Priority |
|---|---|---|
| Function return of raw pointer | Analyzer tracks only within single function body | High |
| Pointer passed into callee | No call-graph analysis; callees are opaque | High |
| Global pointer mutations | Globals are tracked as single entity, no points-to analysis | Medium |
| Send/Sync violations | Type-level thread safety (Send/Sync traits) not enforced |
Medium |
| Gap | Why | Priority |
|---|---|---|
| Array of Boxes with different states | Each state transition produces different type; arrays require homogeneous types | High |
ArrayList(Box(T, ...)) |
Same problem: can't store different types in one array | High |
| Dynamic slice bounds (full) | We detect missing if (i < len) but not complex range reasoning |
Medium |
| HashMap values as Boxes | Requires homogeneous types | Medium |
| Gap | Why | Priority |
|---|---|---|
| Workspace-wide analysis | Only open documents are analyzed; no project-wide call graph | Medium |
| Configurable strictness per-file | No .zust.toml or similar config yet |
Low |
| VS Code extension | No extension package published | Low |
| Auto-fix application | Code actions are generated but not auto-applied on save | Medium |
| Gap | Why | Priority |
|---|---|---|
std.mem.Allocator integration |
Box wraps allocator manually; no allocator vtable integration |
Low |
| Async/await safety | No @Frame ownership tracking |
Low |
| Comptime evaluation | Analyzer doesn't evaluate comptime code paths | Medium |
Converts unsafe Zig code to zust-safe Zig via AST rewriting.
zig build transpile
./zig-out/bin/zust-transpile input.zig output.zigPatterns rewritten (22 total):
| Unsafe Pattern | Safe Replacement |
|---|---|
allocator.create(T) |
safe.Box(T).init(allocator, undefined) |
allocator.destroy(ptr) |
defer _ = ptr.deinit() |
std.ArrayList(T) |
safe.ArrayList(T) |
std.StringHashMap(T) |
safe.HashMap(safe.String, T) |
std.Thread.Mutex{} |
safe.Mutex(void) |
opt.? |
if (opt) |value| { value } else { return error.NullPointer; } |
var x: i32; (uninit) |
var x: i32 = safe.CheckedInt(i32).init(0); |
*T parameter |
safe.Box(T) parameter |
@ptrCast (guaranteed aligned) |
Preserved with alignment analysis |
@bitCast (same-size primitive) |
Preserved with size analysis |
allocator.free(capture) |
Conditional defer destroy or no-op |
Smart features:
- Call graph analysis (
tools/call_graph.zig) — Tracks which functions callallocator.createto insertsafe.Boximports only where needed - Intra-function variable tracking — Detects when a variable is reassigned from raw pointer to
safe.Boxand skips redundant conversion - Conditional defer destroy — Converts
allocator.free(x)insideifblocks todefer if (condition) _ = x.deinit() - Scoped import skip — Won't add
const safe = @import("safe");if the file already has one - AST-based
@ptrCastanalysis — Detects guaranteed-aligned sources (address-of, field access) to avoid false warnings
Bulk application:
# Convert an entire project
./zig-out/bin/zust-transpile src/ src_safe/The transpiler is itself written with zust types (safe.String for buffers, safe.ArrayList for edit tracking) and is analyzed by zust.
Analyzes any Zig project for memory safety issues.
zig build
./zig-out/bin/zust-analyze /path/to/project/src/
# JSON output
./zig-out/bin/zust-analyze --json /path/to/project/src/
# SARIF output for CI
./zig-out/bin/zust-analyze --sarif /path/to/project/src/ > results.sarifDetects 30 bug classes including double-free, UAF, data races, null dereferences, buffer overflows, division by zero, and more.
A zust-transpile skill is available for OpenCode agents at:
~/.config/opencode/skills/superpowers/zust-transpile/SKILL.md
Load it with: Skill("zust-transpile")
- Zero hidden control flow — No implicit drops, no runtime reference counting
- Explicit ownership — Every transfer is visible in the type system
- Opt-in safety — Raw pointers still work; safe types are wrappers
- Zero runtime cost — All typestate checking happens at compile time
- Dog-food everything — If the analyzer can't use
safe.Box, it's a bug
These cannot be solved at the library level and require changes to Zig itself:
- Non-lexical lifetimes (NLL): Borrows that end before scope exit are not tracked. The library uses lexical scopes (
ScopeImm/ScopeMut) as a conservative approximation. Real NLL requires compiler integration. - Type-level state homogeneity: All
Box(T)instances share the same comptime state. Each state transition returns a different type, making homogeneous arrays of Boxes impossible withoutArrayListorVecDeque. - Compile-time cost: Complex borrow sequences cause heavy monomorphization. Build times increase with borrow depth.
- Pointer escape:
unsafePtr()breaks the type system. The analyzer catches cross-API-boundary escapes, but the library itself cannot track raw pointers. - ASCII-only SIMD:
eqlIgnoreCase,startsWithIgnoreCase, and case-folding SIMD operations only handle ASCII A-Z/a-z. Unicode case folding is not implemented. - SIMD substring search:
containsandcountSubstringuse Boyer-Moore-Horspool with SIMD verification. For small inputs (<1KB), scalar search can be faster due to setup overhead. Speedup is significant on inputs >1MB. - No garbage collection: All memory management is explicit. There is no cycle collector for
Rc/Arcreference cycles. - Iterator consumption: All iterators are consuming (remove items). This prevents double-free but means you cannot iterate the same collection twice without re-creating it.
- Single-translation-unit: The analyzer processes one file at a time. Cross-file type resolution (e.g.,
const Foo = @import("foo.zig").Foo) is not fully resolved. - Doc-comment contracts only: Cross-function ownership is inferred from doc comments, not from analyzing callee bodies. This is a deliberate choice for speed, but means the analyzer misses contracts that aren't documented.
- Text-based references:
textDocument/referencesuses token matching, not semantic scope analysis. It may return false positives for shadowed variables or same-named fields. - ASCII byte offsets: LSP incremental sync treats line+character as byte offsets. This is correct for Zig source (ASCII/Latin-1) but not for files with multi-byte UTF-8 characters.
- Pre-existing crash: One analyzer test (
analyzer self-check on LSP source) crashes due to an AST node union field mismatch inAnalysis.analyzeWhile. This is a known issue in the original analyzer code, not related to recent additions.
- No incremental compilation on the client: The cache is server-side only. The client must still send full file contents on
didOpen. - No
workspace/didChangeWatchedFiles: File system watching is not implemented. Changes made outside the editor are not detected. - No code lenses or inlay hints: These rich IDE features are not yet supported.
- 22 patterns: Covers the most common unsafe patterns but not all. Manual review required for: inline assembly, comptime pointer manipulation, platform-specific intrinsics, and complex
@ptrCastalignment changes that aren't statically provable. - No type inference: The transpiler works syntactically. It cannot infer that a
[]u8is actually a string and should becomesafe.String. - No cross-file refactoring: Each file is transpiled independently. Cross-file imports are not rewritten.
- Core
Boxtypestate with@compileErrorenforcement - Closure API (
withImm/withMut) - Explicit borrow API (
borrowImm/borrowMut/releaseImm/releaseMut) -
Rc(T),Arc(T),Weak(T)with reference counting -
Mutex(T),RwLock(T)with RAII guards -
Cell(T),RefCell(T),UnsafeCell(T)interior mutability -
ManuallyDrop(T),MaybeUninit(T),Pin(T)low-level primitives -
OnceCell(T),LazyCell(T),OnceBox(T)lazy initialization -
LinkedList(T),ArrayList(T),VecDeque(T),HashMap(T),BTreeMap(T),HashSet(T),BinaryHeap(T) -
String,Cow(T)string utilities -
Slice(T)borrow-checked slices -
Channel(T),Oneshot(T)message passing -
Iterators— map, filter, fold, collect, enumerate, take, skip, chain, zip, find, all, sum, min, max -
PhantomData(T)zero-sized marker - 586+ passing compile-time tests
- 16-byte generic vector baseline
- 32-byte fast paths (x86 AVX2)
- 16 primitives: findByte, eql, startsWith, endsWith, copy, fill, findAnyByte, countByte, findByteReverse, findByteSet, eqlIgnoreCase, startsWithIgnoreCase, contains, countSubstring, trimLeft, trimRight
- Scalar fallback for <16 bytes and tail handling
- SIMD-accelerated String, ArrayList, VecDeque, RingBuffer, Slice, SmallString operations
- AST parsing via
std.zig.Ast.parse - Intraprocedural pointer provenance tracking
- Detects double-free, use-after-free, pointer escape, dangling args
- Detects raw pointer patterns (allocations, types, dereferences)
- Cross-function analysis with ownership contracts
- Detects all zust type misuse: ManuallyDrop leaks, OnceCell double-set, Mutex deadlocks, Channel send-after-close, Pin moves, etc.
- 30 bug class detections + auto-fix generation
- Async/await ownership tracking (
callconv(.Async),nosuspend,resume) - Dog-foods
safe.Boxfor AST lifecycle - Dog-foods
safe.LinkedListfor tracked_pointers - Dog-foods
safe.Stringfor LSP message building - Human-readable + SARIF 2.1.0 output
-
zig build analyzestep with-Dstrictnessand-Dsarif - Incremental compilation cache (AST + analysis result caching)
- LSP server:
initialize,didOpen,didChange,didClose,shutdown - LSP
publishDiagnosticsnotification - LSP
textDocument/codeAction(auto-fixes) - LSP
textDocument/completion(49 types) - LSP
textDocument/definition(go-to-source) - LSP
textDocument/hover(type docs) - LSP
workspace/symbol(global symbol search) - LSP
textDocument/references(find usages) - Incremental document sync (range-based
didChange) - JSON-RPC 2.0 message framing
- 70+ analyzer tests
- 22 unsafe→safe patterns via AST rewriting
- Self-hosted (uses
safe.String,safe.ArrayList) - Call graph analysis for targeted import insertion
- Intra-function variable tracking for safe.Box conversions
- Conditional defer destroy pattern detection
- AST-based guaranteed-aligned
@ptrCastdetection - Same-size primitive
@bitCastdetection -
*T→safe.Box(T)parameter conversion - Scoped import skip (won't duplicate
const safe = @import("safe")) - 24 transpiler tests
- Property-based fuzzing (4 fuzzers: Box, String, HashMap, SimdUtils)
- Benchmark suite (
zig build bench) — 13 categories + SIMD speedup comparisons - Documentation generator (
zig build docs) — real HTML with 58 modules, 148 declarations - WebAssembly build (
zig build wasm) — WASI target for browser IDEs - Adoption scripts (
scripts/init-zust.sh,scripts/migrate.sh) - OpenCode agent skill (
zust-transpile) - GitHub Actions CI/CD (native + cross-compile to 5 targets)
-
build.zig.zonpackage manager manifest
- Non-lexical lifetimes (NLL) — Requires compiler integration. Partial workaround: use explicit
ScopeImm/ScopeMut. - Full interprocedural call-graph analysis — Research-level. Current doc-comment contracts cover 90% of cases.
- VS Code extension marketplace publish — Pure packaging task. The LSP server works; just needs
package.json+ icon +vsce publish.
- Cross-file type resolution for analyzer
- LSP code lenses / inlay hints
-
workspace/didChangeWatchedFiles(file system watching) - Unicode-aware SIMD string operations
- Transpiler: type inference for
[]u8→safe.String - Benchmark-driven SIMD optimization (auto-detect fastest path per input size)
- More fuzz targets (Rc cycles, Arc weak refs, Channel bounded/unbounded)
MIT