Skip to content

Soundness: RenderStateRowCells::graphemes_buf is safe but passes no capacity to the FFI #70

Description

@molofsky

Hi — found this while debugging something unrelated in a downstream consumer. It didn't turn out to be the cause of the bug I was chasing, so this is a latent soundness report rather than a live incident.

The issue

RenderStateRowCells::graphemes_buf is a safe pub fn, but it hands the C side only a pointer — never the buffer's length:

https://github.com/Uzaaft/libghostty-rs/blob/master/crates/libghostty-vt/src/render.rs#L822-L831

pub fn graphemes_buf(&self, buf: &mut [char]) -> Result<()> {
    let result = unsafe {
        ffi::ghostty_render_state_row_cells_get(
            self.iter.0.as_raw(),
            ffi::RenderStateRowCellsData::GRAPHEMES_BUF,
            buf.as_mut_ptr().cast(),   // <- no capacity
        )
    };
    from_result(result)
}

GRAPHEMES_BUF therefore cannot bound its write or report OUT_OF_SPACE. Safe Rust can call it with a slice that's too small and the write runs past the end of the allocation:

// no `unsafe` anywhere — but this writes out of bounds on any cell with graphemes
let mut buf: [char; 0] = [];
cell.graphemes_buf(&mut buf)?;

The doc comment states the requirement ("The buffer must be at least graphemes_len() elements"), but a safe function can't rely on a doc comment for memory safety.

The two sibling APIs both do bound it

That contrast is what convinced me this is an oversight rather than a deliberate trade-off:

API passed to FFI bounded
GridRef::graphemes (screen.rs:94) ptr, buf.len(), out-len yes
RenderStateRowCells::graphemes_utf8 (render.rs:840) Buffer { ptr, **cap**, len }, retries on OUT_OF_SPACE yes
RenderStateRowCells::graphemes_buf (render.rs:822) ptr only no

There's also a TOCTOU in the safe wrapper

graphemes() reads the length, allocates exactly that, then fills:

pub fn graphemes(&self) -> Result<Vec<char>> {
    let len = self.graphemes_len()?;
    let mut graphemes = vec!['\0'; len];
    self.graphemes_buf(&mut graphemes)?;
    Ok(graphemes)
}

Because the write is unbounded, that length is only advisory — anything that changes the cell's grapheme count between the read and the fill overflows the Vec.

Suggested patch

Two parts. Reimplement graphemes() on the bounded path, and mark graphemes_buf unsafe, since it can't uphold safety with the current C signature:

 pub fn graphemes(&self) -> Result<Vec<char>> {
-    let len = self.graphemes_len()?;
-    let mut graphemes = vec!['\0'; len];
-    self.graphemes_buf(&mut graphemes)?;
-    Ok(graphemes)
+    // `graphemes_utf8` passes ptr + capacity and grows on OUT_OF_SPACE, so it
+    // cannot overflow; `graphemes_buf` has no capacity to check against.
+    let mut s = String::new();
+    self.graphemes_utf8(&mut s)?;
+    Ok(s.chars().collect())
 }

+/// # Safety
+///
+/// The C API for `GRAPHEMES_BUF` receives only a pointer — it is never told how large
+/// `buf` is and cannot bound its write or report `OUT_OF_SPACE`. The caller must
+/// guarantee `buf` has at least [`Self::graphemes_len`] elements *at the moment of this
+/// call*. Prefer [`Self::graphemes_utf8`].
-pub fn graphemes_buf(&self, buf: &mut [char]) -> Result<()> {
+pub unsafe fn graphemes_buf(&self, buf: &mut [char]) -> Result<()> {

I recognise marking a published pub fn unsafe is a breaking change, so you may prefer only the graphemes() half for now, or adding a length parameter to the GRAPHEMES_BUF tag on the C side so it can return OUT_OF_SPACE like the UTF-8 variant does.

Happy to open a PR for whichever shape you'd prefer — I didn't want to presume with a breaking API change. Verified present on master (72ac98f) and in the published 0.2.1.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions