How to Fix: Cranelift: UB in cranelift_jit::JitModule::define_data
Cranelift JIT Undefined Behavior in JitModule::define_data: Root Cause and Safe Fix
A single zero-length data definition can trigger undefined behavior inside Cranelift JIT when std::ptr::copy_nonoverlapping is called with invalid source and destination pointers. Even though no bytes are copied, Rust still requires both pointers to be valid and properly aligned for the given type. That is why passing sentinel-like values such as 1 for src and 0 for dst is still UB.
Table of Contents
The affected code path in the Cranelift JIT backend attempts to materialize data objects into executable memory. In the problematic branch, a zero-sized allocation or empty data payload can lead to a call equivalent to:
std::ptr::copy_nonoverlapping(src, dst, 0);
At first glance, copying zero bytes looks harmless. But Rust’s memory model does not allow arbitrary invalid pointers here, even for a zero-length operation. That is the core bug behind this GitHub issue.
Understanding the Root Cause
The issue comes from the contract of copy_nonoverlapping. Its safety rules are stricter than “no bytes copied means no problem.” Specifically:
- src must be valid for reads of
count * size_of<T>()bytes. - dst must be valid for writes of the same size.
- Both pointers must be properly aligned.
- The regions must not overlap.
For a count of 0, there is no actual memory access, but the pointer validity rules still matter. In practice, this means you cannot safely use a null pointer, integer-cast sentinel, or dangling unaligned pointer just because the length is zero.
In cranelift_jit::JitModule::define_data, the bad state appears when both of these conditions align:
- The data object being defined has zero length, or the emitted memory region has size zero.
- The implementation still reaches a raw pointer copy using invalid placeholder pointers.
A pattern like this is dangerous:
let src = data_bytes.as_ptr(); // may be a non-dereferenceable sentinel for empty slices in some contexts of unsafe composition
let dst = allocated_ptr; // may be null or otherwise invalid for zero-sized allocation
std::ptr::copy_nonoverlapping(src, dst, data_bytes.len());
Even if data_bytes.len() == 0, the call is only sound when both pointers satisfy the API’s preconditions. A null destination is especially problematic. The issue report mentions a case where src can be 1 and dst can be 0; that violates the function contract immediately.
This is a classic unsafe Rust trap: zero-length operations do not automatically relax pointer validity requirements.
Step-by-Step Solution
The safest fix is simple: skip the copy entirely when the length is zero. Do not call copy_nonoverlapping unless there is at least one byte to copy.
1. Guard the copy with a length check
Replace unsafe logic that always calls copy_nonoverlapping with an explicit zero-length fast path.
let len = data.len();
if len != 0 {
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, len);
}
This avoids requiring meaningless pointer validation for a no-op copy and removes the UB.
2. Ensure destination allocation logic matches the copy contract
If the destination pointer comes from a custom allocator or mmap-backed region, make sure the implementation does not assume a zero-sized allocation yields a usable write pointer. For example:
let len = data.len();
let ptr = if len == 0 {
std::ptr::NonNull::dangling().as_ptr()
} else {
allocate_data_region(len)?
};
if len != 0 {
std::ptr::copy_nonoverlapping(data.as_ptr(), ptr, len);
}
Using NonNull::dangling() can be acceptable as an internal placeholder for zero-sized states, but the crucial rule remains: never pass it into a raw memory copy unless the API allows it. Here, the length guard prevents that.
3. Patch the backend implementation
A practical backend-side fix in the affected area looks like this:
let size = bytes.len();
if size > 0 {
unsafe {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), writable_ptr, size);
}
}
If relocation, protection changes, or registration steps happen after the copy, they can still proceed normally as long as they do not assume non-empty storage.
4. Add regression tests for empty data definitions
You should add a test that defines a zero-length data object and verifies that no UB-prone path is taken.
#[test]
fn define_empty_data_does_not_copy_or_crash() {
use cranelift_jit::{JITBuilder, JITModule};
use cranelift_module::{DataContext, Linkage, Module};
let builder = JITBuilder::new(cranelift_module::default_libcall_names());
let mut module = JITModule::new(builder);
let data_id = module
.declare_data("empty_blob", Linkage::Local, false, false)
.unwrap();
let mut data_ctx = DataContext::new();
data_ctx.define(Box::new([]));
module.define_data(data_id, &data_ctx).unwrap();
}
If your local Cranelift version uses newer APIs, adapt the test accordingly, but keep the invariant: empty data must not invoke unsafe copy logic with invalid pointers.
5. Validate with sanitizers and Miri
Because this is a memory safety issue, basic unit tests are not enough. Validate the fix with tools that understand unsafe contracts:
cargo test
cargo +nightly miri test
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test
Miri is especially useful for catching pointer validity mistakes that may not crash in native execution.
6. Prefer higher-level slice copies when possible
If the surrounding design allows it, replacing some raw pointer manipulation with safe slice operations can reduce future UB risks:
if !data.is_empty() {
unsafe {
let dst = std::slice::from_raw_parts_mut(writable_ptr, data.len());
dst.copy_from_slice(data);
}
}
This still uses unsafe to construct the destination slice, but the actual copy is expressed in a safer and clearer form.
Common Edge Cases
Fixing the zero-length copy is the main correction, but several nearby cases deserve attention.
1. Zero-sized allocation with later relocation logic
If relocation code assumes a valid backing buffer exists, empty data may still break later stages. Audit any code that computes offsets or applies relocations to ensure it handles size 0 cleanly.
2. Alignment-sensitive pointer construction
Even a non-null pointer can be invalid if it is not properly aligned. This matters if the code later reinterprets byte pointers as typed pointers. Keep raw data buffers as *mut u8 unless stricter alignment is guaranteed.
3. Read-only versus writable mapping transitions
JIT backends often write data into a temporary writable mapping and then change permissions. Empty mappings or zero-sized sections may not behave like normal memory regions on every platform. Guard permission transitions if the region length is zero.
4. Symbol registration for empty data objects
Some consumers may still expect a symbol address for zero-sized data. If a symbol must exist, represent it consistently, but do not treat that symbolic address as proof that a writable copy target exists.
5. Platform-specific allocator behavior
Some allocators return unique non-null pointers for zero-sized layouts, while others may use special behavior internally. Do not rely on allocator quirks to justify a raw pointer copy with length zero.
6. Future refactors reintroducing UB
This class of bug often returns when someone “simplifies” code by removing the length guard. Add comments near the unsafe block explaining that the check is required for soundness, not just optimization.
// SAFETY: copy_nonoverlapping requires valid, aligned pointers even when len == 0.
// Therefore we must skip the call entirely for empty data.
if len != 0 {
unsafe {
std::ptr::copy_nonoverlapping(src, dst, len);
}
}
FAQ
Why is copy_nonoverlapping(..., 0) UB if no bytes are copied?
Because the function’s contract still requires valid and aligned pointers. Rust treats this as a precondition of the unsafe API, independent of whether the runtime performs an actual memory read or write.
Is checking if len != 0 enough to fix this issue?
Yes for this specific UB path, assuming the only invalid operation is the zero-length call to copy_nonoverlapping. You should still audit surrounding code for other unsafe assumptions about zero-sized data, symbol addresses, and relocation handling.
Could this bug cause real crashes, or is it only theoretical?
It is a real memory safety bug. UB may appear harmless in one build and fail in another under optimization, sanitizers, or different platforms. Even if it does not crash immediately, it violates Rust’s guarantees and must be fixed.
The correct resolution for this Cranelift JIT issue is to treat empty data definitions as a distinct case: allocate or represent them safely, but never call raw copy primitives with invalid pointers just because the byte count is zero. A small guard around the unsafe copy removes the UB and makes JitModule::define_data sound for zero-length data objects.