diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/osc.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/osc.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/osc.zig | 264 |
1 files changed, 247 insertions, 17 deletions
diff --git a/vendor/libghostty-vt/src/terminal/osc.zig b/vendor/libghostty-vt/src/terminal/osc.zig index 26ce1267..dbc35492 100644 --- a/vendor/libghostty-vt/src/terminal/osc.zig +++ b/vendor/libghostty-vt/src/terminal/osc.zig @@ -46,12 +46,13 @@ pub const Command = union(Key) { /// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md semantic_prompt: SemanticPrompt, - /// Set or get clipboard contents. If data is null, then the current - /// clipboard contents are sent to the pty. If data is set, this - /// contents is set on the clipboard. + /// Set or get clipboard contents. If data is "?", then the current + /// clipboard contents are sent to the pty. Otherwise, the contents + /// are set on the clipboard. clipboard_contents: struct { kind: u8, data: [:0]const u8, + terminator: Terminator = .st, }, /// OSC 7. Reports the current working directory of the shell. This is @@ -163,12 +164,17 @@ pub const Command = union(Key) { /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, + /// Kitty desktop notifications (OSC 99) + kitty_desktop_notification: KittyDesktopNotification, + pub const SemanticPrompt = parsers.semantic_prompt.Command; pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; + pub const KittyDesktopNotification = parsers.kitty_desktop_notification.OSC; + pub const Key = LibEnum( lib.target, // NOTE: Order matters, see LibEnum documentation. @@ -199,23 +205,21 @@ pub const Command = union(Key) { "kitty_clipboard_protocol", "kitty_dnd_protocol", "context_signal", + "kitty_desktop_notification", }, ); pub const ProgressReport = struct { - pub const State = enum(c_int) { - remove, - set, - @"error", - indeterminate, - pause, - - test "ghostty.h Command.ProgressReport.State" { - if (comptime build_options.artifact == .lib) return error.SkipZigTest; - try lib.checkGhosttyHEnum(State, "GHOSTTY_PROGRESS_STATE_"); - } + const state_keys = &.{ + "remove", + "set", + "error", + "indeterminate", + "pause", }; + pub const State = LibEnum(lib.target, state_keys); + state: State, progress: ?u8 = null, @@ -235,6 +239,12 @@ pub const Command = union(Key) { )) else -1, }; } + + test "ghostty.h Command.ProgressReport.State" { + if (comptime build_options.artifact == .lib) return error.SkipZigTest; + const CState = LibEnum(.c, state_keys); + try lib.checkGhosttyHEnum(CState, "GHOSTTY_PROGRESS_STATE_"); + } }; comptime { @@ -297,11 +307,20 @@ pub const Parser = struct { /// Maximum size of a "normal" OSC. pub const MAX_BUF = 2048; + /// Maximum size of an OSC that requires dynamically allocated storage. + /// OSC input is untrusted, so these captures must have a finite bound. + pub const MAX_ALLOCATING_BUF = 8 * 1024 * 1024; + /// Optional allocator used to accept data longer than MAX_BUF. /// This only applies to some commands (e.g. OSC 52) that can /// reasonably exceed MAX_BUF. alloc: ?Allocator, + /// Maximum number of bytes retained by an allocating capture. + /// This is configurable primarily so callers and tests can choose a + /// smaller policy than the default. + max_allocating_bytes: usize, + /// Current state of the parser. state: State, @@ -352,6 +371,7 @@ pub const Parser = struct { @"66", @"72", @"77", + @"99", @"104", @"110", @"111", @@ -373,6 +393,7 @@ pub const Parser = struct { pub fn init(alloc: ?Allocator) Parser { var result: Parser = .{ .alloc = alloc, + .max_allocating_bytes = MAX_ALLOCATING_BUF, .state = .start, .capture = null, .command = .invalid, @@ -431,6 +452,7 @@ pub const Parser = struct { .kitty_text_sizing, .kitty_clipboard_protocol, .kitty_dnd_protocol, + .kitty_desktop_notification, .context_signal, => {}, } @@ -458,6 +480,7 @@ pub const Parser = struct { const Capture = struct { writer: *std.Io.Writer, backing: Backing, + max_bytes: usize, const Backing = union(enum) { fixed: std.Io.Writer, @@ -473,22 +496,83 @@ pub const Parser = struct { new.* = .{ .backing = .{ .fixed = .fixed(buf) }, .writer = &new.*.?.backing.fixed, + .max_bytes = buf.len, }; } pub inline fn allocating( new: *?Capture, alloc: Allocator, + max_bytes: usize, ) error{OutOfMemory}!void { new.* = .{ .backing = .{ .allocating = try std.Io.Writer.Allocating.initCapacity( alloc, - 2048, + @min(MAX_BUF, max_bytes), ) }, .writer = &new.*.?.backing.allocating.writer, + .max_bytes = max_bytes, }; } + /// Append one byte without permitting the backing allocation to grow + /// beyond max_bytes. Allocating.Writer normally grows super-linearly, + /// so grow it explicitly to keep the allocation itself bounded too. + pub inline fn writeByte(self: *Capture, byte: u8) error{WriteFailed}!void { + if (self.writer.buffered().len >= self.max_bytes) return error.WriteFailed; + + switch (self.backing) { + .fixed => {}, + .allocating => |*w| { + if (w.writer.end >= w.writer.buffer.len) { + const new_capacity = @min( + self.max_bytes, + @max(w.writer.buffer.len *| 2, 1), + ); + w.writer.buffer = w.allocator.realloc( + w.writer.buffer, + new_capacity, + ) catch return error.WriteFailed; + } + }, + } + + try self.writer.writeByte(byte); + } + + /// Append a slice without permitting the backing allocation to + /// grow beyond max_bytes. This matches the byte-at-a-time + /// semantics of writeByte: bytes are retained up to exactly + /// max_bytes and the first byte that doesn't fit fails the + /// write. + pub fn writeSlice( + self: *Capture, + bytes: []const u8, + ) error{WriteFailed}!void { + const avail = self.max_bytes - self.writer.buffered().len; + const n = @min(bytes.len, avail); + + switch (self.backing) { + .fixed => {}, + .allocating => |*w| { + const needed = w.writer.end + n; + if (needed > w.writer.buffer.len) { + const new_capacity = @min( + self.max_bytes, + @max(w.writer.buffer.len *| 2, needed), + ); + w.writer.buffer = w.allocator.realloc( + w.writer.buffer, + new_capacity, + ) catch return error.WriteFailed; + } + }, + } + + try self.writer.writeAll(bytes[0..n]); + if (n < bytes.len) return error.WriteFailed; + } + pub fn deinit(self: *Capture) void { switch (self.backing) { .fixed => {}, @@ -532,6 +616,7 @@ pub const Parser = struct { Capture.allocating( &self.capture, alloc, + self.max_allocating_bytes, ) catch { // The allocator failed for some reason, fall back to a fixed buffer // and hope that it's big enough. @@ -542,6 +627,33 @@ pub const Parser = struct { } } + /// Consume a slice of bytes, advancing the parser state. This is + /// equivalent to calling `next` for each byte in order, but is much + /// faster once a data capture is active because the remaining bytes + /// are appended to the capture in bulk. + pub fn nextSlice(self: *Parser, input: []const u8) void { + if (self.state == .invalid) return; + + // Run the state machine byte-at-a-time until a capture begins. + // The command prefix before a capture starts is only a handful + // of bytes so this loop is short in practice. + var offset: usize = 0; + while (self.capture == null) { + if (offset >= input.len) return; + self.next(input[offset]); + offset += 1; + if (self.state == .invalid) return; + } + + const rem = input[offset..]; + if (rem.len == 0) return; + self.capture.?.writeSlice(rem) catch |err| switch (err) { + // We have overflowed our buffer or had some other error, set + // the state to invalid so that we discard any further input. + error.WriteFailed => self.state = .invalid, + }; + } + /// Consume the next character c and advance the parser state. pub fn next(self: *Parser, c: u8) void { // If the state becomes invalid for any reason, just discard @@ -551,7 +663,7 @@ pub const Parser = struct { // If a writer has been initialized, we just accumulate the rest of the // OSC sequence in the writer's buffer and skip the state machine. if (self.capture) |*cap| { - cap.writer.writeByte(c) catch |err| switch (err) { + cap.writeByte(c) catch |err| switch (err) { // We have overflowed our buffer or had some other error, set the // state to invalid so that we discard any further input. error.WriteFailed => self.state = .invalid, @@ -740,11 +852,24 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"9", + => switch (c) { + ';' => self.captureTrailing(.fixed), + '9' => self.state = .@"99", + else => self.state = .invalid, + }, + + .@"99", + => switch (c) { + // OSC 99 encoded payloads can exceed the fixed buffer. + ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, + }, + .@"0", .@"22", .@"777", .@"8", - .@"9", => switch (c) { ';' => self.captureTrailing(.fixed), else => self.state = .invalid, @@ -825,6 +950,8 @@ pub const Parser = struct { .@"77" => null, + .@"99" => parsers.kitty_desktop_notification.parse(self, terminator_ch), + .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), .@"552" => null, @@ -842,3 +969,106 @@ test { _ = parsers; _ = encoding; } + +test "Parser allocating captures have a hard limit" { + const testing = std.testing; + const prefixes = [_][]const u8{ "52;", "66;", "72;", "99;", "5522;" }; + const limit = Parser.MAX_BUF + 1; + + for (prefixes) |prefix| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = limit; + + for (prefix) |ch| p.next(ch); + for (0..limit) |_| p.next('a'); + + const cap = &p.capture.?; + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + + p.next('a'); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + } +} + +test "Parser nextSlice allocating captures have a hard limit" { + const testing = std.testing; + const limit = Parser.MAX_BUF + 1; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = limit; + + const data = try testing.allocator.alloc(u8, limit); + defer testing.allocator.free(data); + @memset(data, 'a'); + + // Exactly at the limit stays valid and bounded. + p.nextSlice("52;"); + p.nextSlice(data); + const cap = &p.capture.?; + try testing.expect(p.state != .invalid); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + + // One more byte overflows: the state becomes invalid and the + // retained bytes and allocation stay bounded. + p.nextSlice("a"); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + try testing.expect(p.end(null) == null); +} + +test "Parser nextSlice overflowing slice is truncated at the limit" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = 4; + + p.nextSlice("52;abcdef"); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expect(p.end(null) == null); + + const cap = &p.capture.?; + try testing.expectEqualStrings("abcd", cap.trailing()); + try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len); +} + +test "Parser nextSlice matches per-byte parsing" { + const testing = std.testing; + const input = "52;c;aGVsbG8="; + + // Every two-way split of the input must parse identically to + // the byte-at-a-time path. + for (0..input.len + 1) |split| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.nextSlice(input[0..split]); + p.nextSlice(input[split..]); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual(@as(u8, 'c'), cmd.clipboard_contents.kind); + try testing.expectEqualStrings("aGVsbG8=", cmd.clipboard_contents.data); + } +} + +test "Parser allocating capture limit includes parser-added bytes" { + const testing = std.testing; + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = 4; + + for ("52;abcd") |ch| p.next(ch); + try testing.expect(p.end(null) == null); + try testing.expectEqual(Parser.State.invalid, p.state); + + const cap = &p.capture.?; + try testing.expectEqual(@as(usize, 4), cap.trailing().len); + try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len); +} |
