diff --git a/include/ghostty/vt/osc.h b/include/ghostty/vt/osc.h index f7626383c..7a681c3b4 100644 --- a/include/ghostty/vt/osc.h +++ b/include/ghostty/vt/osc.h @@ -66,6 +66,7 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23, GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24, GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25, + GHOSTTY_OSC_COMMAND_KITTY_DESKTOP_NOTIFICATION = 26, GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandType; diff --git a/src/terminal/c/types.zig b/src/terminal/c/types.zig index bc214c576..95a166c3d 100644 --- a/src/terminal/c/types.zig +++ b/src/terminal/c/types.zig @@ -1000,6 +1000,7 @@ test "manifest uses public enum names" { try std.testing.expectEqual(@as(i64, 23), osc_values.get("KITTY_CLIPBOARD_PROTOCOL").?.integer); try std.testing.expectEqual(@as(i64, 24), osc_values.get("KITTY_DND_PROTOCOL").?.integer); try std.testing.expectEqual(@as(i64, 25), osc_values.get("CONTEXT_SIGNAL").?.integer); + try std.testing.expectEqual(@as(i64, 26), osc_values.get("KITTY_DESKTOP_NOTIFICATION").?.integer); try std.testing.expect(osc_values.contains("TYPE_MAX_VALUE")); try std.testing.expect(!osc_values.contains("MAX_VALUE")); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 63d65b743..7edabcdc0 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -159,13 +159,13 @@ pub const Command = union(Key) { /// Kitty drag and drop protocol (OSC 72) kitty_dnd_protocol: KittyDndProtocol, - /// Kitty desktop notifications (OSC 99) - kitty_desktop_notification: KittyDesktopNotification, - /// OSC 3008. Hierarchical context signalling (UAPI spec). /// 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; @@ -203,8 +203,8 @@ pub const Command = union(Key) { "kitty_text_sizing", "kitty_clipboard_protocol", "kitty_dnd_protocol", - "kitty_desktop_notification", "context_signal", + "kitty_desktop_notification", }, ); @@ -800,7 +800,7 @@ pub const Parser = struct { .@"99", => switch (c) { - // OSC 99 can be up to 4096 bytes fully encoded. + // OSC 99 encoded payloads can exceed the fixed buffer. ';' => self.captureTrailing(.allocating), else => self.state = .invalid, }, @@ -911,7 +911,7 @@ test { test "Parser allocating captures have a hard limit" { const testing = std.testing; - const prefixes = [_][]const u8{ "52;", "66;", "72;", "5522;" }; + const prefixes = [_][]const u8{ "52;", "66;", "72;", "99;", "5522;" }; const limit = Parser.MAX_BUF + 1; for (prefixes) |prefix| { diff --git a/src/terminal/osc/kitty_metadata.zig b/src/terminal/osc/kitty_metadata.zig new file mode 100644 index 000000000..469c97a0f --- /dev/null +++ b/src/terminal/osc/kitty_metadata.zig @@ -0,0 +1,94 @@ +//! Helpers for parsing metadata shared by Kitty OSC protocols. +//! +//! Kitty OSC 99 and OSC 5522 encode metadata as colon-separated `key=value` +//! fields. The iterator in this module lazily searches that metadata for one +//! key, preserving the order of repeated values without allocating. +//! +//! Parsing is intentionally tolerant. Whitespace around keys and values is +//! trimmed, while malformed fields, non-matching keys, and invalid values are +//! skipped. Returned values are slices of the original metadata and remain +//! valid only as long as that input remains valid. + +const std = @import("std"); + +/// Return an iterator over values whose key exactly matches `key`. +/// +/// If `valid_value_characters` is non-null, every byte in a returned value must +/// appear in that character set. Passing null disables value validation. Both +/// arguments are comptime-known so each protocol can specialize the iterator +/// for its metadata grammar without storing a key or validator at runtime. +pub fn ValueIterator( + comptime key: []const u8, + comptime valid_value_characters: ?[]const u8, +) type { + return struct { + const Self = @This(); + + metadata: []const u8, + pos: usize, + + /// Initialize an iterator borrowing `metadata`. + pub fn init(metadata: []const u8) Self { + return .{ + .metadata = metadata, + .pos = 0, + }; + } + + /// Return the next valid matching value, or null when none remain. + /// The returned slice borrows the metadata passed to `init`. + pub fn next(self: *Self) ?[]const u8 { + while (self.pos < self.metadata.len) { + const end = std.mem.indexOfScalarPos( + u8, + self.metadata, + self.pos, + ':', + ) orelse self.metadata.len; + const field = self.metadata[self.pos..end]; + self.pos = if (end < self.metadata.len) end + 1 else end; + + const equals = std.mem.indexOfScalar(u8, field, '=') orelse + continue; + const field_key = std.mem.trim( + u8, + field[0..equals], + &std.ascii.whitespace, + ); + if (!std.mem.eql(u8, field_key, key)) continue; + + const value = std.mem.trim( + u8, + field[equals + 1 ..], + &std.ascii.whitespace, + ); + if (valid_value_characters) |valid| { + if (std.mem.indexOfNone(u8, value, valid) != null) continue; + } + + return value; + } + + return null; + } + }; +} + +test "ValueIterator skips malformed and prefix-matching keys" { + const testing = std.testing; + var it: ValueIterator("id", null) = .init( + "id-extra=wrong:id: id = first :id=second", + ); + + try testing.expectEqualStrings("first", it.next().?); + try testing.expectEqualStrings("second", it.next().?); + try testing.expect(it.next() == null); +} + +test "ValueIterator skips values containing disallowed characters" { + const testing = std.testing; + var it: ValueIterator("i", "abc") = .init("i=a?:i=abc"); + + try testing.expectEqualStrings("abc", it.next().?); + try testing.expect(it.next() == null); +} diff --git a/src/terminal/osc/lib.zig b/src/terminal/osc/lib.zig deleted file mode 100644 index dfa1a59b7..000000000 --- a/src/terminal/osc/lib.zig +++ /dev/null @@ -1,61 +0,0 @@ -const std = @import("std"); - -/// Used to iterate over matching key/values in the metadata -pub fn Iterator(comptime Option: type, comptime isValidMetadataValue: fn ([]const u8) bool, comptime key: Option) type { - return struct { - const Self = @This(); - - metadata: []const u8, - pos: usize, - - pub fn init(metadata: []const u8) Self { - return .{ - .metadata = metadata, - .pos = 0, - }; - } - - /// Return the value of the next matching key. The value is guaranteed - /// to be `null` or a valid metadata value. - pub fn next(self: *Self) ?[]const u8 { - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - while (self.pos < self.metadata.len) { - // skip any whitespace - while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - if (!std.mem.startsWith(u8, self.metadata[self.pos..], @tagName(key))) { - // this isn't the key we are looking for, skip to the next option, or bail if - // there is no next option - self.pos = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse { - self.pos = self.metadata.len; - return null; - }; - self.pos += 1; - continue; - } - // skip past the key - self.pos += @tagName(key).len; - // skip any whitespace - while (self.pos < self.metadata.len and std.ascii.isWhitespace(self.metadata[self.pos])) self.pos += 1; - // bail if we are out of metadata - if (self.pos >= self.metadata.len) return null; - // a valid option has an '=' - if (self.metadata[self.pos] != '=') return null; - // the end of the value is bounded by a ':' or the end of the metadata - const end = std.mem.indexOfScalarPos(u8, self.metadata, self.pos, ':') orelse self.metadata.len; - const start = self.pos + 1; - self.pos = end + 1; - // strip any leading or trailing whitespace - const value = std.mem.trim(u8, self.metadata[start..end], &std.ascii.whitespace); - // if this is not a valid value, skip it - if (!@call(.always_inline, isValidMetadataValue, .{value})) continue; - // return the value - return value; - } - // the key was not found - return null; - } - }; -} diff --git a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index db6efc048..3b32aae8b 100644 --- a/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -10,7 +10,7 @@ const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const Iterator = @import("../lib.zig").Iterator; +const kitty_metadata = @import("../kitty_metadata.zig"); const encoding = @import("../encoding.zig"); const log = std.log.scoped(.kitty_clipboard_protocol); @@ -97,7 +97,7 @@ pub const Option = enum { comptime key: Option, metadata: []const u8, ) ?key.Type() { - var it: Iterator(Option, isValidMetadataValue, key) = .init(metadata); + var it: kitty_metadata.ValueIterator(@tagName(key), null) = .init(metadata); const value = it.next() orelse return null; // return the parsed value @@ -127,10 +127,6 @@ fn parseIdentifier(str: []const u8) ?[]const u8 { return null; } -fn isValidMetadataValue(_: []const u8) bool { - return true; -} - pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"5522"); diff --git a/src/terminal/osc/parsers/kitty_desktop_notification.zig b/src/terminal/osc/parsers/kitty_desktop_notification.zig index 2c7857493..27fc86702 100644 --- a/src/terminal/osc/parsers/kitty_desktop_notification.zig +++ b/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -2,20 +2,20 @@ //! Specification: https://sw.kovidgoyal.net/kitty/desktop-notifications/ const std = @import("std"); -const build_options = @import("terminal_options"); const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const Iterator = @import("../lib.zig").Iterator; +const kitty_metadata = @import("../kitty_metadata.zig"); const encoding = @import("../encoding.zig"); -const lib = @import("../../../lib/main.zig"); -const lib_target: lib.Target = if (build_options.c_abi) .c else .zig; const log = std.log.scoped(.kitty_desktop_notification); +pub const MAX_PLAIN_PAYLOAD_BYTES = 2048; +pub const MAX_ENCODED_PAYLOAD_BYTES = 4096; + pub const OSC = struct { /// The raw metadata that was received. It can be parsed by using the `readOption` method. metadata: []const u8, @@ -137,11 +137,17 @@ pub const Option = enum { .f => ?[]const u8, .g => ?[]const u8, .i => ?[]const u8, - .n => Iterator(Option, isValidMetadataValue, .n), + .n => kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ), .o => Occasion, .p => Payload, .s => []const u8, - .t => Iterator(Option, isValidMetadataValue, .t), + .t => kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ), .u => Urgency, .w => i32, }; @@ -168,13 +174,16 @@ pub const Option = enum { /// Read the option value from the raw metadata string. /// - /// Any errors in the raw string will return null since the OSC 99 - /// specification says to ignore unknown or malformed options. + /// Unknown and malformed values are ignored. Optional values return null; + /// all other values return the protocol default. pub fn read( comptime key: Option, metadata: []const u8, ) key.Type() { - var it: Iterator(Option, isValidMetadataValue, key) = switch (key) { + var it: kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ) = switch (key) { .t, .n => return .init(metadata), else => .init(metadata), }; @@ -222,7 +231,7 @@ fn parseBool(str: []const u8) ?bool { /// This is similar to the packed struct parser used in the configs. The /// differences are that a literal `true` or `false` value does not turn on/off /// all the values, and the negation prefix is `-` not `no-`. -pub fn parsePackedStruct(comptime T: type, str: []const u8) T { +fn parsePackedStruct(comptime T: type, str: []const u8) T { const info = @typeInfo(T).@"struct"; comptime assert(info.layout == .@"packed"); @@ -262,14 +271,10 @@ pub fn parsePackedStruct(comptime T: type, str: []const u8) T { /// against the spec but is needed since Base64 encoded values (with padding) /// are valid for some options. Including `?` is technically against the spec /// but is needed since it is a valid value for the `p` option. -const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/.,(){}[]*&^%$#@!`~=?"; - -fn isValidMetadataValue(str: []const u8) bool { - return std.mem.indexOfNone(u8, str, valid_metadata_value_characters) == null; -} +const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/,(){}[]*&^%$#@!`~=?"; /// Characters that are valid in identifiers. -const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+"; +const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+."; fn isValidIdentifier(str: []const u8) bool { return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null; @@ -299,7 +304,20 @@ pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { const metadata = data[0..payload_start]; const payload = data[payload_start + 1 .. data.len]; - // Payload has to be a URL-safe UTF-8 string. + const max_payload_bytes: usize = if (Option.e.read(metadata)) + MAX_ENCODED_PAYLOAD_BYTES + else + MAX_PLAIN_PAYLOAD_BYTES; + if (payload.len > max_payload_bytes) { + log.warn( + "payload is too large: size={d} max={d}", + .{ payload.len, max_payload_bytes }, + ); + parser.state = .invalid; + return null; + } + + // Payload has to be an escape-code-safe UTF-8 string. if (!encoding.isSafeUtf8(payload)) { log.warn("payload is not escape code safe UTF-8", .{}); parser.state = .invalid; @@ -386,18 +404,58 @@ test "OSC 99: empty metadata with payload" { try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); } +test "OSC 99: payload size limits" { + const testing = std.testing; + const cases = [_]struct { + metadata: []const u8, + payload_size: usize, + valid: bool, + }{ + .{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES, .valid = true }, + .{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES + 1, .valid = false }, + .{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES, .valid = true }, + .{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES + 1, .valid = false }, + }; + + for (cases) |case| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + for ("99;") |ch| p.next(ch); + for (case.metadata) |ch| p.next(ch); + p.next(';'); + for (0..case.payload_size) |_| p.next('a'); + + try testing.expectEqual(case.valid, p.end('\x1b') != null); + } +} + +test "OSC 99: unknown prefix does not hide dotted identifier" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "99;invalid=wrong:i=org.ghostty;payload"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expectEqualStrings( + "org.ghostty", + cmd.kitty_desktop_notification.readOption(.i).?, + ); +} + test "OSC 99: single parameter i" { const testing = std.testing; var p: Parser = .init(null); - const input = "99;i=bobr;kurwa"; + const input = "99;i=bobr;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_desktop_notification); try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); - try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload); } test "OSC 99: repeated parameter i" { @@ -405,13 +463,13 @@ test "OSC 99: repeated parameter i" { var p: Parser = .init(null); - const input = "99;i=bobr:i=foobar;kurwa"; + const input = "99;i=bobr:i=foobar;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_desktop_notification); try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); - try testing.expectEqualStrings("kurwa", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload); } test "OSC 99: multiple types" { @@ -419,16 +477,16 @@ test "OSC 99: multiple types" { var p: Parser = .init(null); - const input = "99;t=bobr: t = kurwa : t = ghostty ;foobar"; + const input = "99;t=mail: t = chat : t = alert ;notification"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_desktop_notification); - try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("notification", cmd.kitty_desktop_notification.payload); var it = cmd.kitty_desktop_notification.readOption(.t); - try testing.expectEqualStrings("bobr", it.next().?); - try testing.expectEqualStrings("kurwa", it.next().?); - try testing.expectEqualStrings("ghostty", it.next().?); + try testing.expectEqualStrings("mail", it.next().?); + try testing.expectEqualStrings("chat", it.next().?); + try testing.expectEqualStrings("alert", it.next().?); try testing.expect(it.next() == null); }