Description
From<Slice<T>> for PtrSlice<T> takes the raw pointer out of a Slice and
hands it to NonNull::new_unchecked without checking it.
glib/src/collections/ptr_slice.rs:1091 in 0.22.9:
let ptr = value.into_raw();
let mut s = PtrSlice::<T> {
ptr: ptr::NonNull::new_unchecked(ptr),
len,
capacity,
};
// Reserve space for the `NULL`-terminator if needed
if len == capacity {
s.reserve(0);
}
ptr::write(
s.ptr.as_ptr().add(s.len()),
Ptr::from(ptr::null_mut::<<T as GlibPtrDefault>::GlibType>()),
);
Slice::into_raw returns null for any empty slice, at
glib/src/collections/slice.rs:590:
pub fn into_raw(mut self) -> *mut T::GlibType {
if self.len == 0 {
ptr::null_mut()
} else {
self.len = 0;
self.capacity = 0;
self.ptr.as_ptr()
}
}
So the conversion feeds null straight into NonNull::new_unchecked. That is
already undefined behavior, before the reserve on the next line has any chance
to install a real pointer. There are two ways in, both from safe code, and the
second is worse than the first.
Slice::new(): an invalid NonNull
len and capacity are both zero, so NonNull::new_unchecked(null) runs, then
len == capacity holds, reserve(0) allocates, and the rest of the function
proceeds over a valid buffer. The only damage is the invalid NonNull that
briefly existed, which is enough: the niche it occupies is a validity invariant
the compiler is allowed to rely on anywhere in the program. From
PtrSlice::from(Slice::<GStringPtr>::new()), cargo +nightly miri test against
0.22.9 unmodified:
error: Undefined Behavior: constructing invalid value of type std::ptr::NonNull<*mut i8>:
at .pointer, encountered 0, but expected something greater or equal to 1
--> src/collections/ptr_slice.rs:1098:22
Slice::with_capacity(n): a freed buffer and a write through null
With capacity > 0 and len == 0, three things go wrong in sequence:
into_rawtakes thelen == 0branch and returns null without clearinglenandcapacity.into_rawtookselfby value, so theSlicedrops at the end of it.Drop(glib/src/collections/slice.rs:75) readscapacity, sees it is non-zero, and callsffi::g_free. The buffer is gone.- Back in
from, the newPtrSlicerecords that same non-zerocapacityalongside the null pointer.len == capacityis now false, soreservenever runs, andptr::write(s.ptr.as_ptr().add(0), ...)writes through null.
The returned PtrSlice also claims ownership of a capacity that was already
freed. Its own Drop sees capacity != 0 and calls g_free again, this time on
the null pointer, which GLib tolerates, so the second free is harmless in
practice. The write through null is not.
Miri cannot run this case, because with_capacity calls into GLib to allocate,
but native runs show it plainly:
// capacity > 0, len == 0.
let reserved: Slice<GStringPtr> = Slice::with_capacity(4);
let owned: PtrSlice<GStringPtr> = PtrSlice::from(reserved);
A debug build hits std's own precondition check, unsafe precondition(s) violated: NonNull::new_unchecked requires that the pointer is non-null. In
release, where that check is compiled out, the optimizer acts on the null
dereference and the test dies on signal: 4, SIGILL: illegal instruction.
Impact
Undefined behavior reachable from safe code in a crate that sits underneath most
of the Rust GTK ecosystem. Converting an empty-but-reserved Slice writes
through a null pointer and aborts the process, so it is a denial of service at
minimum, and the resulting PtrSlice reports capacity over memory that was
already freed. The invalid NonNull is worse than an unfortunate value: it
contradicts a validity invariant, so the compiler may optimize on the assumption
that the code is unreachable, which is what the release build above did.
Reaching it takes nothing exotic, only an empty collection returned from a GLib
call or a generic helper that converts a Slice without checking whether it is
empty.
Solution
Fixed in commit b31265d, merged through gtk-rs/gtk-rs-core#2048 on 2026-09-19. The conversion now checks the pointer before wrapping it:
// `into_raw()` returns `null` for an empty slice; in that case
// any reserved buffer has already been freed.
let ptr = value.into_raw();
if ptr.is_null() {
debug_assert_eq!(len, 0);
PtrSlice::new()
} else {
// ... unchanged path
}
Slice::into_raw also gained a doc line recording that an empty slice returns
NULL and that any reserved capacity is freed, which is the part of its contract
the caller had silently assumed away.
The fix is on main only. The newest published glib is 0.22.9 from 2026-08-30,
which is still affected. Until a release lands, guard the conversion with
slice.is_empty() and build PtrSlice::new() directly, or pin glib to a git
revision at or after the fix.
Timeline
- 2026-09-08: Reported publicly as gtk-rs/gtk-rs-core#2042, found with my undefined-behavior static analysis tool.
- 2026-09-08: Maintainer acknowledged the report.
- 2026-09-19: Fix merged into
mainand the issue closed. No published release contains the fix yet.