Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 116 additions & 6 deletions lib/std/array_list.zig

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions lib/std/debug.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pub const Pdb = @import("debug/Pdb.zig");
pub const SelfInfo = @import("debug/SelfInfo.zig");
pub const Info = @import("debug/Info.zig");
pub const Coverage = @import("debug/Coverage.zig");
pub const Asan = @import("debug/Asan.zig");

pub const simple_panic = @import("debug/simple_panic.zig");
pub const no_panic = @import("debug/no_panic.zig");
Expand Down Expand Up @@ -1780,4 +1781,5 @@ test {
_ = &Pdb;
_ = &SelfInfo;
_ = &dumpHex;
_ = @import("debug/asan_test.zig");
}
83 changes: 83 additions & 0 deletions lib/std/debug/Asan.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! AddressSanitizer manual-poisoning helpers.
//!
//! These wrap the ASAN runtime's manual-poison API so that allocators and
//! containers can mark freed/unused regions as inaccessible. All functions
//! compile to no-ops when `builtin.sanitize_address` is false, so call sites
//! need no `if` guard.
//!
//! Poisoning is shadow-byte granular (8 bytes on most targets). A region
//! whose start is not 8-aligned may leave the first partial qword unpoisoned;
//! this is a limitation of the ASAN shadow encoding, not of these wrappers.

const builtin = @import("builtin");

/// Whether the current compilation has AddressSanitizer instrumentation.
/// Guarded with @hasDecl so the bootstrap stage1 (whose builtin predates
/// sanitize_address) can still compile std.
pub const enabled = @hasDecl(builtin, "sanitize_address") and builtin.sanitize_address;

/// Mark `[ptr, ptr+len)` as inaccessible. Any subsequent load or store in
/// that range triggers an ASAN `use-after-poison` report.
pub inline fn poison(ptr: [*]const u8, len: usize) void {
if (!enabled) return;
__asan_poison_memory_region(ptr, len);
}

/// Mark `[ptr, ptr+len)` as accessible again.
pub inline fn unpoison(ptr: [*]const u8, len: usize) void {
if (!enabled) return;
__asan_unpoison_memory_region(ptr, len);
}

/// Convenience overload taking any slice.
pub inline fn poisonSlice(slice: anytype) void {
if (!enabled) return;
if (slice.len == 0) return;
const bytes = @import("std").mem.sliceAsBytes(slice);
__asan_poison_memory_region(bytes.ptr, bytes.len);
}

/// Convenience overload taking any slice.
pub inline fn unpoisonSlice(slice: anytype) void {
if (!enabled) return;
if (slice.len == 0) return;
const bytes = @import("std").mem.sliceAsBytes(slice);
__asan_unpoison_memory_region(bytes.ptr, bytes.len);
}

/// Tell ASAN about a contiguous container's `[storage, storage+capacity)`
/// where only `[storage, storage+new_len)` is valid. Equivalent to libc++'s
/// vector annotation. `old_len` must be the value passed as `new_len` on the
/// previous call (or `capacity` on first call). All lengths are in bytes.
pub inline fn annotateContiguousContainer(
storage: [*]const u8,
capacity: usize,
old_len: usize,
new_len: usize,
) void {
if (!enabled) return;
if (capacity == 0) return;
__sanitizer_annotate_contiguous_container(
storage,
storage + capacity,
storage + old_len,
storage + new_len,
);
}
Comment on lines +52 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add fail-fast precondition checks for container annotation bounds.

The docstring defines the contract, but Line 60 currently trusts callers completely. Adding debug assertions for old_len/new_len against capacity would catch misuse earlier.

Suggested diff
 pub inline fn annotateContiguousContainer(
     storage: [*]const u8,
     capacity: usize,
     old_len: usize,
     new_len: usize,
 ) void {
     if (!enabled) return;
     if (capacity == 0) return;
+    `@import`("std").debug.assert(old_len <= capacity);
+    `@import`("std").debug.assert(new_len <= capacity);
     __sanitizer_annotate_contiguous_container(
         storage,
         storage + capacity,
         storage + old_len,
         storage + new_len,
     );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub inline fn annotateContiguousContainer(
storage: [*]const u8,
capacity: usize,
old_len: usize,
new_len: usize,
) void {
if (!enabled) return;
if (capacity == 0) return;
__sanitizer_annotate_contiguous_container(
storage,
storage + capacity,
storage + old_len,
storage + new_len,
);
}
pub inline fn annotateContiguousContainer(
storage: [*]const u8,
capacity: usize,
old_len: usize,
new_len: usize,
) void {
if (!enabled) return;
if (capacity == 0) return;
`@import`("std").debug.assert(old_len <= capacity);
`@import`("std").debug.assert(new_len <= capacity);
__sanitizer_annotate_contiguous_container(
storage,
storage + capacity,
storage + old_len,
storage + new_len,
);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/std/debug/Asan.zig` around lines 52 - 66, annotateContiguousContainer
trusts callers; add fail-fast debug precondition checks to validate bounds
before calling __sanitizer_annotate_contiguous_container: assert that capacity
!= 0 (already checked), that old_len <= capacity, new_len <= capacity, and that
old_len <= new_len (or vice‑versa if the function expects shrink/expand
semantics), and optionally assert storage is non-null when enabled; place these
checks in annotateContiguousContainer near the top so misuse is caught in debug
builds before invoking __sanitizer_annotate_contiguous_container.


/// Returns true if the byte at `ptr` is currently poisoned. Intended for
/// assertions in tests.
pub inline fn isPoisoned(ptr: *const anyopaque) bool {
if (!enabled) return false;
return __asan_address_is_poisoned(ptr) != 0;
}

extern fn __asan_poison_memory_region(addr: [*]const u8, size: usize) void;
extern fn __asan_unpoison_memory_region(addr: [*]const u8, size: usize) void;
extern fn __asan_address_is_poisoned(addr: *const anyopaque) c_int;
extern fn __sanitizer_annotate_contiguous_container(
beg: [*]const u8,
end: [*]const u8,
old_mid: [*]const u8,
new_mid: [*]const u8,
) void;
129 changes: 129 additions & 0 deletions lib/std/debug/asan_test.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Tests that the manual ASAN poisoning applied by std allocators and
//! containers actually marks the right bytes. These tests query the ASAN
//! shadow via `Asan.isPoisoned` rather than dereferencing poisoned memory,
//! so they pass cleanly under `-fsanitize-address` instead of crashing.
//!
//! Every test is gated on `Asan.enabled` and skipped otherwise so that the
//! file can be built into the regular (non-sanitized) test binary without
//! producing false failures.
//!
//! Run directly with:
//! zig test lib/std/debug/asan_test.zig --zig-lib-dir lib -fsanitize-address -lc

const std = @import("std");
const testing = std.testing;
const Asan = std.debug.Asan;

test "asan: ArrayList spare capacity is poisoned" {
if (!Asan.enabled) return error.SkipZigTest;

const gpa = testing.allocator;
var list: std.ArrayList(u64) = try .initCapacity(gpa, 8);
defer list.deinit(gpa);

try testing.expectEqual(@as(usize, 8), list.capacity);

try list.append(gpa, 1);
try list.append(gpa, 2);
try list.append(gpa, 3);

// Base of the backing buffer (items.ptr is a [*]u64; treat as bytes for
// shadow queries so we are explicit about granularity).
const base: [*]const u8 = @ptrCast(list.items.ptr);

// items[0..3] live, items[3..8] poisoned spare capacity.
try testing.expect(!Asan.isPoisoned(&base[0 * @sizeOf(u64)]));
try testing.expect(!Asan.isPoisoned(&base[2 * @sizeOf(u64)]));
try testing.expect(Asan.isPoisoned(&base[3 * @sizeOf(u64)]));
try testing.expect(Asan.isPoisoned(&base[7 * @sizeOf(u64)]));

// Popping shrinks the live region; the just-vacated slot becomes poison.
_ = list.pop();
try testing.expect(!Asan.isPoisoned(&base[1 * @sizeOf(u64)]));
try testing.expect(Asan.isPoisoned(&base[2 * @sizeOf(u64)]));
}

test "asan: FixedBufferAllocator poisons freed regions" {
if (!Asan.enabled) return error.SkipZigTest;

// 8-byte alignment so the ASAN shadow boundaries line up exactly with
// the buffer; otherwise the first/last partial qword may be left
// unpoisoned by the runtime.
var buf: [256]u8 align(8) = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buf);
defer fba.deinit();
const a = fba.allocator();

// init() does not poison eagerly (Zig has no per-frame shadow cleanup yet).
try testing.expect(!Asan.isPoisoned(&buf[0]));
try testing.expect(!Asan.isPoisoned(&buf[100]));

const slab = try a.alloc(u8, 64);
try testing.expect(!Asan.isPoisoned(&slab[0]));
try testing.expect(!Asan.isPoisoned(&slab[32]));

// free() poisons the returned region so use-after-free is caught.
a.free(slab);
try testing.expect(Asan.isPoisoned(&buf[0]));
try testing.expect(Asan.isPoisoned(&buf[32]));

// A fresh allocation past the high-water mark is still addressable.
const slab2 = try a.alloc(u8, 64);
try testing.expect(!Asan.isPoisoned(&slab2[0]));

// reset() poisons everything that was handed out so far.
fba.reset();
try testing.expect(Asan.isPoisoned(&slab2[0]));
}

test "asan: ArenaAllocator reset(.retain_capacity) re-poisons" {
if (!Asan.enabled) return error.SkipZigTest;

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const a = arena.allocator();

const slab = try a.alloc(u64, 16);
const base: [*]const u8 = @ptrCast(slab.ptr);
try testing.expect(!Asan.isPoisoned(&base[0]));
try testing.expect(!Asan.isPoisoned(&base[15 * @sizeOf(u64)]));

// Keep the buffer but rewind end_index to 0; the previously handed-out
// bytes must now be inaccessible.
_ = arena.reset(.retain_capacity);
try testing.expect(Asan.isPoisoned(&base[0]));
try testing.expect(Asan.isPoisoned(&base[8 * @sizeOf(u64)]));
}

test "asan: MemoryPool destroy poisons item tail" {
if (!Asan.enabled) return error.SkipZigTest;

// Item large enough that there is a poisonable tail past the free-list
// `next` pointer (one usize).
const Item = struct { data: [32]u8 };

var pool = std.heap.MemoryPool(Item).init(testing.allocator);
defer pool.deinit();

const item = try pool.create();
const bytes: [*]const u8 = @ptrCast(item);

// Freshly created: whole item is addressable.
try testing.expect(!Asan.isPoisoned(&bytes[0]));
try testing.expect(!Asan.isPoisoned(&bytes[@sizeOf(Item) - 1]));

pool.destroy(item);

// First @sizeOf(usize) bytes hold the free-list link and stay unpoisoned;
// everything after is poison.
const link_end = @sizeOf(usize);
try testing.expect(!Asan.isPoisoned(&bytes[0]));
try testing.expect(Asan.isPoisoned(&bytes[link_end]));
try testing.expect(Asan.isPoisoned(&bytes[@sizeOf(Item) - 1]));

// Re-create pulls from the free list and unpoisons the full item again.
const item2 = try pool.create();
try testing.expectEqual(@intFromPtr(item), @intFromPtr(item2));
try testing.expect(!Asan.isPoisoned(&bytes[link_end]));
try testing.expect(!Asan.isPoisoned(&bytes[@sizeOf(Item) - 1]));
}
37 changes: 32 additions & 5 deletions lib/std/heap.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const c = std.c;
const Allocator = std.mem.Allocator;
const windows = std.os.windows;
const Alignment = std.mem.Alignment;
const Asan = std.debug.Asan;

pub const ArenaAllocator = @import("heap/arena_allocator.zig").ArenaAllocator;
pub const SmpAllocator = @import("heap/SmpAllocator.zig");
Expand Down Expand Up @@ -406,6 +407,10 @@ pub fn StackFallbackAllocator(comptime size: usize) type {
self.get_called = true;
}
self.fixed_buffer_allocator = FixedBufferAllocator.init(self.buffer[0..]);
// The unallocated tail is intentionally not poisoned: Zig does not
// yet emit per-frame ASAN shadow cleanup, so eager poisoning would
// leave stale poison after the owning frame returns. Freed regions
// are still poisoned (see free/resize).
return .{
.ptr = self,
.vtable = &.{
Expand All @@ -430,8 +435,11 @@ pub fn StackFallbackAllocator(comptime size: usize) type {
ra: usize,
) ?[*]u8 {
const self: *Self = @ptrCast(@alignCast(ctx));
return FixedBufferAllocator.alloc(&self.fixed_buffer_allocator, len, alignment, ra) orelse
return self.fallback_allocator.rawAlloc(len, alignment, ra);
if (FixedBufferAllocator.alloc(&self.fixed_buffer_allocator, len, alignment, ra)) |ptr| {
Asan.unpoison(ptr, len);
return ptr;
}
return self.fallback_allocator.rawAlloc(len, alignment, ra);
}

fn resize(
Expand All @@ -443,7 +451,15 @@ pub fn StackFallbackAllocator(comptime size: usize) type {
) bool {
const self: *Self = @ptrCast(@alignCast(ctx));
if (self.fixed_buffer_allocator.ownsPtr(buf.ptr)) {
return FixedBufferAllocator.resize(&self.fixed_buffer_allocator, buf, alignment, new_len, ra);
const ok = FixedBufferAllocator.resize(&self.fixed_buffer_allocator, buf, alignment, new_len, ra);
if (ok) {
if (new_len > buf.len) {
Asan.unpoison(buf.ptr + buf.len, new_len - buf.len);
} else if (new_len < buf.len) {
Asan.poison(buf.ptr + new_len, buf.len - new_len);
}
}
return ok;
} else {
return self.fallback_allocator.rawResize(buf, alignment, new_len, ra);
}
Expand All @@ -458,7 +474,16 @@ pub fn StackFallbackAllocator(comptime size: usize) type {
) ?[*]u8 {
const self: *Self = @ptrCast(@alignCast(context));
if (self.fixed_buffer_allocator.ownsPtr(memory.ptr)) {
return FixedBufferAllocator.remap(&self.fixed_buffer_allocator, memory, alignment, new_len, return_address);
const result = FixedBufferAllocator.remap(&self.fixed_buffer_allocator, memory, alignment, new_len, return_address);
if (result) |ptr| {
// FixedBufferAllocator.remap only ever resizes in place.
if (new_len > memory.len) {
Asan.unpoison(ptr + memory.len, new_len - memory.len);
} else if (new_len < memory.len) {
Asan.poison(ptr + new_len, memory.len - new_len);
}
}
return result;
} else {
return self.fallback_allocator.rawRemap(memory, alignment, new_len, return_address);
}
Expand All @@ -472,7 +497,9 @@ pub fn StackFallbackAllocator(comptime size: usize) type {
) void {
const self: *Self = @ptrCast(@alignCast(ctx));
if (self.fixed_buffer_allocator.ownsPtr(buf.ptr)) {
return FixedBufferAllocator.free(&self.fixed_buffer_allocator, buf, alignment, ra);
FixedBufferAllocator.free(&self.fixed_buffer_allocator, buf, alignment, ra);
Asan.poison(buf.ptr, buf.len);
return;
} else {
return self.fallback_allocator.rawFree(buf, alignment, ra);
}
Expand Down
27 changes: 24 additions & 3 deletions lib/std/heap/FixedBufferAllocator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,30 @@ const std = @import("../std.zig");
const Allocator = std.mem.Allocator;
const assert = std.debug.assert;
const mem = std.mem;
const Asan = std.debug.Asan;

const FixedBufferAllocator = @This();

end_index: usize,
buffer: []u8,

pub fn init(buffer: []u8) FixedBufferAllocator {
// The unallocated tail is intentionally not poisoned here: Zig does not
// yet emit per-frame ASAN shadow cleanup, so poisoning a stack-backed
// buffer would leave stale poison after the FBA goes out of scope.
// Freed regions are still poisoned (see free/resize/reset).
return .{
.buffer = buffer,
.end_index = 0,
};
}

/// Unpoisons the backing buffer so it can be reused outside this allocator.
pub fn deinit(self: *FixedBufferAllocator) void {
if (!@inComptime()) Asan.unpoisonSlice(self.buffer);
self.* = undefined;
}

/// Using this at the same time as the interface returned by `threadSafeAllocator` is not thread safe.
pub fn allocator(self: *FixedBufferAllocator) Allocator {
return .{
Expand Down Expand Up @@ -68,7 +79,9 @@ pub fn alloc(ctx: *anyopaque, n: usize, alignment: mem.Alignment, ra: usize) ?[*
const new_end_index = adjusted_index + n;
if (new_end_index > self.buffer.len) return null;
self.end_index = new_end_index;
return self.buffer.ptr + adjusted_index;
const result = self.buffer.ptr + adjusted_index;
if (!@inComptime()) Asan.unpoison(result, n);
return result;
}

pub fn resize(
Expand All @@ -85,18 +98,21 @@ pub fn resize(

if (!self.isLastAllocation(buf)) {
if (new_size > buf.len) return false;
if (!@inComptime()) Asan.poison(buf.ptr + new_size, buf.len - new_size);
return true;
}

if (new_size <= buf.len) {
const sub = buf.len - new_size;
self.end_index -= sub;
if (!@inComptime()) Asan.poison(buf.ptr + new_size, sub);
return true;
}

const add = new_size - buf.len;
if (add + self.end_index > self.buffer.len) return false;

if (!@inComptime()) Asan.unpoison(buf.ptr + buf.len, add);
self.end_index += add;
return true;
}
Expand Down Expand Up @@ -125,6 +141,7 @@ pub fn free(
if (self.isLastAllocation(buf)) {
self.end_index -= buf.len;
}
if (!@inComptime()) Asan.poisonSlice(buf);
}

fn threadSafeAlloc(ctx: *anyopaque, n: usize, alignment: mem.Alignment, ra: usize) ?[*]u8 {
Expand All @@ -137,12 +154,16 @@ fn threadSafeAlloc(ctx: *anyopaque, n: usize, alignment: mem.Alignment, ra: usiz
const adjusted_index = end_index + adjust_off;
const new_end_index = adjusted_index + n;
if (new_end_index > self.buffer.len) return null;
end_index = @cmpxchgWeak(usize, &self.end_index, end_index, new_end_index, .seq_cst, .seq_cst) orelse
return self.buffer[adjusted_index..new_end_index].ptr;
end_index = @cmpxchgWeak(usize, &self.end_index, end_index, new_end_index, .seq_cst, .seq_cst) orelse {
const result = self.buffer.ptr + adjusted_index;
Asan.unpoison(result, n);
return result;
};
}
}

pub fn reset(self: *FixedBufferAllocator) void {
if (!@inComptime()) Asan.poisonSlice(self.buffer[0..self.end_index]);
self.end_index = 0;
}

Expand Down
Loading
Loading