mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
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:
@@ -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(¬ification.title, 0));
|
||||
try std.testing.expectEqualStrings("Body", std.mem.sliceTo(¬ification.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 = "aЯ";
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user