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.
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_bufis a safepub 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
GRAPHEMES_BUFtherefore cannot bound its write or reportOUT_OF_SPACE. Safe Rust can call it with a slice that's too small and the write runs past the end of the allocation: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:
GridRef::graphemes(screen.rs:94)buf.len(), out-lenRenderStateRowCells::graphemes_utf8(render.rs:840)Buffer { ptr, **cap**, len }, retries onOUT_OF_SPACERenderStateRowCells::graphemes_buf(render.rs:822)There's also a TOCTOU in the safe wrapper
graphemes()reads the length, allocates exactly that, then fills: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 markgraphemes_bufunsafe, 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 fnunsafeis a breaking change, so you may prefer only thegraphemes()half for now, or adding a length parameter to theGRAPHEMES_BUFtag on the C side so it can returnOUT_OF_SPACElike 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.