termio: preserve UTF-8 in desktop notification truncation (#13811)

## Summary

- Prevent desktop notification title and body truncation from producing
invalid UTF-8.
- Truncate fixed-size buffers to the longest valid UTF-8 prefix.
- Add regression tests for multibyte characters at the buffer boundary.

Fixes #13795

## Testing

- Confirmed the original reproducer produces `[Invalid UTF-8]` with the
installed Ghostty.
- Confirmed the patched Ghostty displays a valid, truncated
notification.
- Added tests covering both notification title and body truncation.

## AI Usage Disclosure

I used OpenAI Codex to investigate the root cause, implement the fix and
regression tests, run validation, and help prepare the issue and PR
descriptions. I reviewed and understand the submitted change.
This commit is contained in:
Mitchell Hashimoto
2026-08-17 11:18:59 -07:00
committed by GitHub
2 changed files with 120 additions and 18 deletions

View File

@@ -16,6 +16,47 @@ pub const Message = union(enum) {
/// we want this union to be.
pub const WriteReq = MessageData(u8, 255);
/// A fixed-size desktop notification payload sent to the app thread.
pub const DesktopNotification = struct {
/// Desktop notification title.
title: [63:0]u8,
/// Desktop notification body.
body: [255:0]u8,
pub fn init(title: []const u8, body: []const u8) DesktopNotification {
var result: DesktopNotification = undefined;
copyUtf8Z(result.title.len, &result.title, title);
copyUtf8Z(result.body.len, &result.body, body);
return result;
}
/// UTF-8 continuation bytes occupy the range 0x80 through 0xBF.
fn isUtf8ContinuationByte(byte: u8) bool {
return switch (byte) {
0x80...0xBF => true,
else => false,
};
}
/// Copy as much of `src` as fits, backing up from a UTF-8 continuation
/// byte so valid input is never truncated in the middle of a codepoint.
fn copyUtf8Z(
comptime capacity: usize,
dst: *[capacity:0]u8,
src: []const u8,
) void {
var len = @min(src.len, capacity);
while (len > 0 and
len < src.len and
isUtf8ContinuationByte(src[len])) : (len -= 1)
{}
@memcpy(dst[0..len], src[0..len]);
dst[len] = 0;
}
};
/// Set the title of the surface.
/// TODO: we should change this to a "WriteReq" style structure in
/// the termio message so that we can more efficiently send strings
@@ -52,13 +93,7 @@ pub const Message = union(enum) {
child_exited: ChildExited,
/// Show a desktop notification.
desktop_notification: struct {
/// Desktop notification title.
title: [63:0]u8,
/// Desktop notification body.
body: [255:0]u8,
},
desktop_notification: DesktopNotification,
/// Health status change for the renderer.
renderer_health: renderer.Health,
@@ -195,3 +230,78 @@ pub fn newConfig(
return copy;
}
test "DesktopNotification init" {
const notification = Message.DesktopNotification.init("Title", "Body");
try std.testing.expectEqualStrings("Title", std.mem.sliceTo(&notification.title, 0));
try std.testing.expectEqualStrings("Body", std.mem.sliceTo(&notification.body, 0));
}
test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" {
const DesktopNotification = Message.DesktopNotification;
var dst_1_byte: [1:0]u8 = undefined;
const src_ending_in_1_byte_codepoint = "ab";
try std.testing.expect(!DesktopNotification.isUtf8ContinuationByte(
src_ending_in_1_byte_codepoint[dst_1_byte.len],
));
DesktopNotification.copyUtf8Z(
dst_1_byte.len,
&dst_1_byte,
src_ending_in_1_byte_codepoint,
);
try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0));
var dst_2_bytes: [2:0]u8 = undefined;
const src_ending_in_2_byte_codepoint = "";
try std.testing.expect(DesktopNotification.isUtf8ContinuationByte(
src_ending_in_2_byte_codepoint[dst_2_bytes.len],
));
DesktopNotification.copyUtf8Z(
dst_2_bytes.len,
&dst_2_bytes,
src_ending_in_2_byte_codepoint,
);
try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0));
var dst_3_bytes: [3:0]u8 = undefined;
const src_ending_in_3_byte_codepoint = "a€";
try std.testing.expect(DesktopNotification.isUtf8ContinuationByte(
src_ending_in_3_byte_codepoint[dst_3_bytes.len],
));
DesktopNotification.copyUtf8Z(
dst_3_bytes.len,
&dst_3_bytes,
src_ending_in_3_byte_codepoint,
);
try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0));
var dst_4_bytes: [4:0]u8 = undefined;
const src_ending_in_4_byte_codepoint = "a😀";
try std.testing.expect(DesktopNotification.isUtf8ContinuationByte(
src_ending_in_4_byte_codepoint[dst_4_bytes.len],
));
DesktopNotification.copyUtf8Z(
dst_4_bytes.len,
&dst_4_bytes,
src_ending_in_4_byte_codepoint,
);
try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0));
}
test "copyUtf8Z keeps a complete codepoint at the truncation boundary" {
var dst: [5:0]u8 = undefined;
Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯz");
try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0));
}
test "copyUtf8Z preserves UTF-8 that fits" {
var dst: [5:0]u8 = undefined;
Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯ");
try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0));
}

View File

@@ -1346,17 +1346,9 @@ pub const StreamHandler = struct {
title: []const u8,
body: []const u8,
) !void {
var message = apprt.surface.Message{ .desktop_notification = undefined };
const title_len = @min(title.len, message.desktop_notification.title.len);
@memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]);
message.desktop_notification.title[title_len] = 0;
const body_len = @min(body.len, message.desktop_notification.body.len);
@memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]);
message.desktop_notification.body[body_len] = 0;
self.surfaceMessageWriter(message);
self.surfaceMessageWriter(.{
.desktop_notification = .init(title, body),
});
}
/// Send a report to the pty.