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:

  1. into_raw takes the len == 0 branch and returns null without clearing len and capacity.
  2. into_raw took self by value, so the Slice drops at the end of it. Drop (glib/src/collections/slice.rs:75) reads capacity, sees it is non-zero, and calls ffi::g_free. The buffer is gone.
  3. Back in from, the new PtrSlice records that same non-zero capacity alongside the null pointer. len == capacity is now false, so reserve never runs, and ptr::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 main and the issue closed. No published release contains the fix yet.

References